Prepare v0.1.16 multi-platform release

This commit is contained in:
David Perov 2026-05-12 16:43:21 +03:00
parent af206402f3
commit d681b15f9a
16 changed files with 395 additions and 114 deletions

View file

@ -14,11 +14,30 @@ permissions:
contents: write
jobs:
build-and-release-macos:
runs-on: macos-latest
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: macos
runner: macos-latest
release_script: build:release:macos
updater_platform: darwin-aarch64
- platform: windows
runner: windows-latest
release_script: build:release:windows
updater_platform: windows-x86_64
- platform: linux
runner: ubuntu-22.04
release_script: build:release:linux
updater_platform: linux-x86_64
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
MACOSX_DEPLOYMENT_TARGET: "11.0"
TALKIS_STT_RELEASE: "1"
TALKIS_UPDATER_PLATFORM: ${{ matrix.updater_platform }}
steps:
- name: Checkout repository
@ -32,6 +51,16 @@ jobs:
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install Linux Tauri dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
@ -61,29 +90,46 @@ jobs:
cargo check --manifest-path src-tauri/Cargo.toml
bun run smoke:hotkey
- name: Build macOS bundles
run: |
bun run prepare:sidecars
bun run build
bun run tauri build
- name: Build bundles
run: bun run ${{ matrix.release_script }}
env:
TALKIS_STT_RELEASE: "1"
TALKIS_SKIP_BEFORE_BUILD: "1"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Archive macOS app bundle
if: matrix.platform == 'macos'
run: ditto -c -k --sequesterRsrc --keepParent "src-tauri/target/release/bundle/macos/Talkis.app" "Talkis-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}-macos.zip"
- name: Upload release bundle
uses: actions/upload-artifact@v4
with:
name: talkis-${{ matrix.platform }}-release
path: |
src-tauri/target/release/bundle/**/*
Talkis-*-macos.zip
if-no-files-found: error
publish:
name: Publish release
needs: build
runs-on: ubuntu-22.04
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download release bundles
uses: actions/download-artifact@v4
with:
path: release-artifacts
- name: Create updater metadata
run: bash scripts/create-updater-latest-json.sh "${RELEASE_TAG#v}" "${RELEASE_TAG}"
env:
BUILD_ROOT: src-tauri/target/release/bundle
- name: Post-process macOS release artifacts
run: bash scripts/postprocess-macos-release.sh "${RELEASE_TAG#v}"
env:
BUILD_ROOT: src-tauri/target/release/bundle
- name: Archive macOS app bundle
run: ditto -c -k --sequesterRsrc --keepParent "src-tauri/target/release/bundle/macos/Talkis.app" "Talkis-${{ env.RELEASE_TAG }}-macos.zip"
BUILD_ROOT: release-artifacts
TALKIS_REQUIRED_UPDATER_PLATFORMS: darwin-aarch64,windows-x86_64,linux-x86_64
- name: Publish GitHub release assets
uses: softprops/action-gh-release@v2
@ -93,31 +139,20 @@ jobs:
body: |
Automated release for ${{ env.RELEASE_TAG }}.
Current workflow publishes all currently supported release artifacts and updater metadata, which is macOS only right now.
Windows and Linux are intentionally disabled until platform-specific support is implemented.
This release publishes native Tauri bundles built on macOS, Windows, and Linux runners.
Windows/Linux builds are unsigned in this workflow.
draft: false
prerelease: ${{ contains(env.RELEASE_TAG, '-') }}
files: |
src-tauri/target/release/bundle/dmg/*.dmg
src-tauri/target/release/bundle/macos/*.app.tar.gz
src-tauri/target/release/bundle/macos/*.app.tar.gz.sig
src-tauri/target/release/bundle/latest.json
Talkis-${{ env.RELEASE_TAG }}-macos.zip
release-artifacts/**/*.dmg
release-artifacts/**/*.app.tar.gz
release-artifacts/**/*.exe
release-artifacts/**/*.msi
release-artifacts/**/*.AppImage
release-artifacts/**/*.deb
release-artifacts/**/*.rpm
release-artifacts/**/*.zip
release-artifacts/**/*.sig
release-artifacts/**/latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
disabled-platform-placeholders:
if: ${{ false }}
strategy:
fail-fast: false
matrix:
include:
- runner: windows-latest
target: windows
- runner: ubuntu-22.04
target: linux
runs-on: ${{ matrix.runner }}
steps:
- name: Placeholder
run: |
echo "${{ matrix.target }} releases are intentionally disabled until platform support is implemented."

View file

@ -1,6 +1,6 @@
# Talkis
Talkis is a lightweight macOS voice-to-text app built with Tauri.
Talkis is a lightweight desktop voice-to-text app built with Tauri.
It sits in a small floating widget, listens while you hold a hotkey, sends audio for transcription, cleans up the text with an LLM, and pastes the result into the active app.
@ -59,24 +59,29 @@ Supported managed local models include:
Local STT is transcription-only. Talkis does not run LLM/style cleanup for local STT mode unless a separate LLM endpoint is configured in custom mode.
## macOS only
## Supported platforms
Talkis is currently designed for macOS.
Talkis release automation builds native bundles for:
- macOS
- Windows
- Linux
The app relies on:
- microphone access
- accessibility permission for automatic text pasting (via CGEvent)
- accessibility permission on macOS for automatic text pasting (via CGEvent)
- best-effort paste simulation on Windows/Linux
- a global hotkey
## Setup
Before first use, make sure you have:
1. macOS
1. macOS, Windows, or Linux
2. One of the access modes configured (subscription, own key, or local model)
3. Microphone access enabled
4. Accessibility access enabled for Talkis
4. Accessibility access enabled for Talkis on macOS
### 1. Open the settings window
@ -259,7 +264,10 @@ bunx tsc --noEmit
cargo check --manifest-path src-tauri/Cargo.toml
bun run check:release
bun run tauri build
bun run build:release
bun run build:release:macos
bun run build:release:windows
bun run build:release:linux
bun run logs
bun run logs:clear
```
@ -270,9 +278,9 @@ The repository includes a GitHub Actions workflow at `.github/workflows/release.
- The canonical release process is documented in `docs/release/rule.md`
- Before every release, refresh `README.md` and create a release review file from `docs/release/review-template.md`
- Push a tag like `v0.1.15` to build and publish a GitHub Release
- Push a tag like `v0.1.16` to build and publish a GitHub Release
- Or run the workflow manually and provide a tag
- The current workflow publishes macOS release artifacts and updater metadata
- The current workflow publishes macOS, Windows, and Linux release artifacts plus updater metadata
- For macOS release builds, move `Talkis.app` to `Applications` before granting Accessibility access
Optional macOS signing/notarization secrets:
@ -284,7 +292,7 @@ Optional macOS signing/notarization secrets:
- `APPLE_PASSWORD`
- `APPLE_TEAM_ID`
Without these secrets, the workflow can still produce unsigned macOS release artifacts.
Without Apple secrets, the workflow can still produce unsigned macOS release artifacts. Windows and Linux builds are currently unsigned.
## Tech stack
@ -298,4 +306,4 @@ Without these secrets, the workflow can still produce unsigned macOS release art
## Status
Talkis is an active work in progress. Current version: **0.1.15**.
Talkis is an active work in progress. Current version: **0.1.16**.

View file

@ -20,6 +20,8 @@ Copy this file to `docs/release/review-vX.Y.Z.md` for each release.
- `bun run check:release`
- `bun run build:release:macos`
- Native/GitHub Windows build:
- Native/GitHub Linux build:
- Additional manual checks:
## Manual review

View file

@ -0,0 +1,58 @@
# Release Review v0.1.16
## Release
- Version: 0.1.16
- Release branch: main
- Target tag: v0.1.16
- Reviewer: Codex
- Date: 2026-05-12
## Scope
- Key changes included in this release:
- Enables GitHub Actions release builds for macOS, Windows, and Linux.
- Adds platform-specific release scripts for macOS, Windows, and Linux.
- Generates updater metadata for `darwin-aarch64`, `windows-x86_64`, and `linux-x86_64`.
- Updates onboarding/runtime info so Windows and Linux do not require macOS Accessibility or Applications-folder handling.
- Marks Qwen and NVIDIA local runtimes as macOS-only while keeping Whisper available on Windows/Linux.
- User-facing changes:
- GitHub Release assets should include Windows and Linux installers in addition to macOS artifacts.
- Onboarding text now reflects platform-specific permissions.
- Risky areas:
- First Windows/Linux release artifacts are built only in GitHub Actions native runners.
- Windows and Linux builds are unsigned.
- Updater metadata depends on `.sig` files being produced for all required platforms.
## Checks run
- `bun run check:release`: passed
- `bun run build:release:macos`: not run locally before tagging; GitHub Actions will build native release bundles.
- Native/GitHub Windows build: pending GitHub Actions for `v0.1.16`
- Native/GitHub Linux build: pending GitHub Actions for `v0.1.16`
- Additional manual checks:
- `scripts/create-updater-latest-json.sh` tested against synthetic macOS/Windows/Linux updater artifacts and signatures.
- `.github/workflows/release.yml` parsed as valid YAML.
## Manual review
- Hotkey flow: smoke tests passed.
- Onboarding permissions: reviewed platform-specific macOS/Windows/Linux copy and gating.
- Widget position and notice behavior: not functionally changed.
- Transcription quality and short-utterance handling: not functionally changed.
- README refreshed: yes.
## Findings
- Blockers: none before tag publish.
- Non-blocking issues:
- Windows/Linux release artifacts still need confirmation from the first Actions run.
- Windows/Linux installers are unsigned.
- Follow-ups after release:
- Verify `v0.1.16` GitHub Release contains Windows `.exe`/`.msi`, Linux `.AppImage`/`.deb`, matching `.sig` files, and `latest.json`.
- If GitHub Actions fails on a native runner dependency, fix the workflow and re-run the tag workflow before announcing the release.
## Decision
- Ready for `main` merge: yes
- Ready for tag publish: yes

View file

@ -19,6 +19,7 @@ This file defines the mandatory release workflow for Talkis. Follow it for every
4. Run the release checks locally:
- `bun run check:release`
- `TAURI_SIGNING_PRIVATE_KEY_PATH=~/.tauri/talkis-updater.key bun run build:release:macos`
- On native Windows/Linux runners, run `bun run build:release:windows` and `bun run build:release:linux` before claiming those artifacts are ready.
- If the updater private key is password-protected, also set `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`.
5. Perform a detailed self-review of the full release diff.
6. Write the review results to `docs/release/review-vX.Y.Z.md` using the review template.
@ -35,11 +36,11 @@ This file defines the mandatory release workflow for Talkis. Follow it for every
- Widget position, notices, and onboarding permissions behave correctly.
- Short or noisy recordings do not paste obvious hallucinated text.
- `bun run check:release` passes.
- Local production build passes via `bun run build:release:macos`.
- Local production build passes via `bun run build:release:macos`; Windows/Linux production builds pass on their native runners or in GitHub Actions.
- Version numbers and release tag match.
- The GitHub Actions release workflow still matches the documented process.
- GitHub repository secrets include `TAURI_SIGNING_PRIVATE_KEY` and, if the key is password-protected, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`.
- GitHub Release includes `latest.json`, the macOS updater `.app.tar.gz`, and its `.sig` file.
- GitHub Release includes `latest.json`, macOS `.app.tar.gz`, Windows `.exe`, Linux `.AppImage`, and matching `.sig` files.
## GitHub Actions release source of truth

View file

@ -1,7 +1,7 @@
{
"name": "talkis",
"private": true,
"version": "0.1.15",
"version": "0.1.16",
"type": "module",
"packageManager": "bun@1.2.13",
"scripts": {
@ -16,7 +16,10 @@
"check:versions": "bash scripts/check-version-sync.sh",
"check:release": "bun run check:versions && bun run check && bun run smoke:hotkey && bun run build",
"postprocess:macos-release": "VERSION=$(python3 -c \"import json; print(json.load(open('package.json'))['version'])\") && BUILD_ROOT=src-tauri/target/release/bundle bash scripts/postprocess-macos-release.sh \"$VERSION\"",
"build:release:macos": "bash scripts/build-release-macos.sh",
"build:release": "bun scripts/build-release.mjs",
"build:release:macos": "bun scripts/build-release.mjs macos",
"build:release:windows": "bun scripts/build-release.mjs windows",
"build:release:linux": "bun scripts/build-release.mjs linux",
"preview": "vite preview",
"tauri": "tauri",
"logs": "tail -f ~/.talkis/talkis.log",

View file

@ -5,17 +5,4 @@ set -euo pipefail
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
export TALKIS_STT_RELEASE=1
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" && -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]]; then
if [[ ! -f "${TAURI_SIGNING_PRIVATE_KEY_PATH}" ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY_PATH does not point to a file: ${TAURI_SIGNING_PRIVATE_KEY_PATH}" >&2
exit 1
fi
export TAURI_SIGNING_PRIVATE_KEY
TAURI_SIGNING_PRIVATE_KEY="$(cat "${TAURI_SIGNING_PRIVATE_KEY_PATH}")"
fi
bun run prepare:sidecars
bun run build
TALKIS_SKIP_BEFORE_BUILD=1 bun run tauri build
bun run postprocess:macos-release
bun scripts/build-release.mjs macos

60
scripts/build-release.mjs Normal file
View file

@ -0,0 +1,60 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
function detectPlatform() {
if (process.platform === "darwin") return "macos";
if (process.platform === "win32") return "windows";
if (process.platform === "linux") return "linux";
throw new Error(`Unsupported release platform: ${process.platform}`);
}
const platform = process.argv[2] || process.env.TALKIS_RELEASE_PLATFORM || detectPlatform();
const bundleTargets = {
macos: ["app", "dmg"],
windows: ["nsis"],
linux: ["appimage", "deb"],
};
if (!Object.hasOwn(bundleTargets, platform)) {
throw new Error(`Unsupported release platform: ${platform}`);
}
const env = {
...process.env,
TALKIS_STT_RELEASE: process.env.TALKIS_STT_RELEASE || "1",
TALKIS_SKIP_BEFORE_BUILD: "1",
};
if (platform === "macos") {
env.MACOSX_DEPLOYMENT_TARGET = process.env.MACOSX_DEPLOYMENT_TARGET || "11.0";
if (!env.TAURI_SIGNING_PRIVATE_KEY && env.TAURI_SIGNING_PRIVATE_KEY_PATH) {
if (!existsSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH)) {
throw new Error(`TAURI_SIGNING_PRIVATE_KEY_PATH does not point to a file: ${env.TAURI_SIGNING_PRIVATE_KEY_PATH}`);
}
env.TAURI_SIGNING_PRIVATE_KEY = readFileSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH, "utf8");
}
}
function run(command, args) {
execFileSync(command, args, {
cwd: rootDir,
env,
stdio: "inherit",
});
}
run("bun", ["run", "prepare:sidecars"]);
run("bun", ["run", "build"]);
run("bun", ["run", "tauri", "build", "--bundles", bundleTargets[platform].join(",")]);
if (platform === "macos" && process.env.TALKIS_POSTPROCESS_MACOS_RELEASE !== "0") {
run("bun", ["run", "postprocess:macos-release"]);
}
console.log(`Built ${platform} release bundles: ${bundleTargets[platform].join(", ")}`);

View file

@ -6,42 +6,72 @@ VERSION="${1:?usage: create-updater-latest-json.sh <version> <release-tag>}"
RELEASE_TAG="${2:?usage: create-updater-latest-json.sh <version> <release-tag>}"
REPOSITORY="${GITHUB_REPOSITORY:-SerTimBerrners-Lee/talkis}"
BUILD_ROOT="${BUILD_ROOT:-src-tauri/target/release/bundle}"
MACOS_BUNDLE_DIR="${BUILD_ROOT}/macos"
LATEST_JSON_PATH="${BUILD_ROOT}/latest.json"
REQUIRED_PLATFORMS="${TALKIS_REQUIRED_UPDATER_PLATFORMS:-}"
UPDATER_ARCHIVE="$(find "${MACOS_BUNDLE_DIR}" -maxdepth 1 -type f -name "*.app.tar.gz" | head -n 1)"
if [[ -z "${UPDATER_ARCHIVE}" ]]; then
echo "Updater archive not found in ${MACOS_BUNDLE_DIR}" >&2
if [[ ! -d "${BUILD_ROOT}" ]]; then
echo "Build root not found: ${BUILD_ROOT}" >&2
exit 1
fi
SIGNATURE_PATH="${UPDATER_ARCHIVE}.sig"
if [[ ! -f "${SIGNATURE_PATH}" ]]; then
echo "Updater signature not found at ${SIGNATURE_PATH}" >&2
exit 1
fi
UPDATER_FILE="$(basename "${UPDATER_ARCHIVE}")"
SIGNATURE="$(tr -d '\n' < "${SIGNATURE_PATH}")"
URL="https://github.com/${REPOSITORY}/releases/download/${RELEASE_TAG}/${UPDATER_FILE}"
PUB_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
python3 - "$LATEST_JSON_PATH" "$VERSION" "$PUB_DATE" "$SIGNATURE" "$URL" <<'PY'
python3 - "$BUILD_ROOT" "$LATEST_JSON_PATH" "$VERSION" "$PUB_DATE" "$REPOSITORY" "$RELEASE_TAG" "$REQUIRED_PLATFORMS" <<'PY'
import json
from pathlib import Path
import sys
path, version, pub_date, signature, url = sys.argv[1:]
build_root, path, version, pub_date, repository, release_tag, required_platforms = sys.argv[1:]
root = Path(build_root)
platform_specs = {
"darwin-aarch64": ["*.app.tar.gz"],
"windows-x86_64": ["*.exe", "*.msi"],
"linux-x86_64": ["*.AppImage"],
}
def find_artifact(patterns):
for pattern in patterns:
matches = sorted(
candidate
for candidate in root.rglob(pattern)
if candidate.is_file()
and not candidate.name.endswith(".sig")
)
if matches:
return matches[0]
return None
platforms = {}
for platform, patterns in platform_specs.items():
artifact = find_artifact(patterns)
if artifact is None:
continue
signature_path = artifact.with_name(f"{artifact.name}.sig")
if not signature_path.is_file():
raise SystemExit(f"Updater signature not found for {platform}: {signature_path}")
signature = signature_path.read_text(encoding="utf-8").strip()
url = f"https://github.com/{repository}/releases/download/{release_tag}/{artifact.name}"
platforms[platform] = {
"signature": signature,
"url": url,
}
required = [item.strip() for item in required_platforms.split(",") if item.strip()]
missing = [platform for platform in required if platform not in platforms]
if missing:
raise SystemExit(f"Missing required updater platforms: {', '.join(missing)}")
if not platforms:
raise SystemExit(f"No updater artifacts found in {root}")
payload = {
"version": version,
"pub_date": pub_date,
"platforms": {
"darwin-aarch64": {
"signature": signature,
"url": url,
},
},
"platforms": platforms,
}
with open(path, "w", encoding="utf-8") as f:

2
src-tauri/Cargo.lock generated
View file

@ -4654,7 +4654,7 @@ dependencies = [
[[package]]
name = "talkis"
version = "0.1.15"
version = "0.1.16"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",

View file

@ -1,7 +1,7 @@
[package]
name = "talkis"
version = "0.1.15"
description = "Talkis - Voice to Text macOS Widget"
version = "0.1.16"
description = "Talkis - Voice to Text Desktop Widget"
authors = ["trixter"]
edition = "2021"
default-run = "talkis"

View file

@ -3,6 +3,7 @@ use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppRuntimeInfo {
platform: String,
executable_path: String,
bundle_path: String,
launched_via_translocation: bool,
@ -10,6 +11,18 @@ pub struct AppRuntimeInfo {
should_move_to_applications: bool,
}
fn platform_name() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
fn build_runtime_info() -> Result<AppRuntimeInfo, String> {
let executable_path = std::env::current_exe().map_err(|e| e.to_string())?;
let executable_path_str = executable_path.to_string_lossy().to_string();
@ -24,13 +37,14 @@ fn build_runtime_info() -> Result<AppRuntimeInfo, String> {
// In dev builds, never warn about Applications — the binary is
// expected to live wherever target-dir points.
let should_move = if cfg!(dev) {
let should_move = if cfg!(dev) || !cfg!(target_os = "macos") {
false
} else {
launched_via_translocation || launched_from_mounted_volume
};
Ok(AppRuntimeInfo {
platform: platform_name().to_string(),
executable_path: executable_path_str,
bundle_path: bundle_path_str,
launched_via_translocation,

View file

@ -109,6 +109,21 @@ fn simulate_cmd_v() -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "macos")]
fn paste_shortcut_label() -> &'static str {
"Cmd+V via CGEvent"
}
#[cfg(target_os = "windows")]
fn paste_shortcut_label() -> &'static str {
"Ctrl+V via input simulation"
}
#[cfg(target_os = "linux")]
fn paste_shortcut_label() -> &'static str {
"Ctrl+V via best-effort input simulation"
}
/// Paste text by writing to clipboard and simulating Cmd+V
#[tauri::command]
pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<(), String> {
@ -140,7 +155,7 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<(), Strin
std::thread::sleep(Duration::from_millis(100));
logger::log_info("PASTE", "Simulating Cmd+V via CGEvent");
logger::log_info("PASTE", &format!("Simulating {}", paste_shortcut_label()));
simulate_cmd_v()?;
std::thread::sleep(Duration::from_millis(350));

View file

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Talkis",
"mainBinaryName": "Talkis",
"version": "0.1.15",
"version": "0.1.16",
"identifier": "com.trixter.talkis",
"build": {
"beforeDevCommand": "bun run prepare:sidecars && bun run dev",
@ -38,7 +38,7 @@
},
"bundle": {
"active": true,
"targets": ["app"],
"targets": "all",
"createUpdaterArtifacts": true,
"externalBin": [
"binaries/ffmpeg",

View file

@ -103,6 +103,7 @@ interface PermissionScreenProps {
}
interface AppRuntimeInfo {
platform: "macos" | "windows" | "linux" | "unknown";
executablePath: string;
bundlePath: string;
launchedViaTranslocation: boolean;
@ -110,6 +111,38 @@ interface AppRuntimeInfo {
shouldMoveToApplications: boolean;
}
type DesktopPlatform = AppRuntimeInfo["platform"];
function detectDesktopPlatform(): DesktopPlatform {
if (typeof navigator === "undefined") {
return "unknown";
}
const value = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
if (value.includes("mac")) return "macos";
if (value.includes("win")) return "windows";
if (value.includes("linux") || value.includes("x11")) return "linux";
return "unknown";
}
function microphoneHelpText(platform: DesktopPlatform): string {
if (platform === "macos") {
return "Если микрофон был отклонен, откройте Системные настройки -> Конфиденциальность -> Микрофон и включите Talkis.";
}
if (platform === "windows") {
return "Если микрофон был отклонен, откройте Параметры -> Конфиденциальность и безопасность -> Микрофон и разрешите доступ для Talkis.";
}
if (platform === "linux") {
return "Если микрофон недоступен, проверьте системные настройки звука и разрешения браузерного WebView для записи.";
}
return "Если микрофон был отклонен, откройте системные настройки приватности и разрешите доступ для Talkis.";
}
export function PermissionScreen({ onComplete }: PermissionScreenProps) {
const [micStatus, setMicStatus] = useState<PermissionStatus>("unknown");
const [accStatus, setAccStatus] = useState<PermissionStatus>("unknown");
@ -190,6 +223,11 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
return;
}
if (!requiresAccessibility) {
setAccStatus("granted");
return;
}
try {
await invoke("reset_accessibility_permission");
} catch (e) {
@ -213,7 +251,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
const { nextMicStatus, nextAccStatus } = await refreshAllPermissions();
if (nextMicStatus !== "granted" || nextAccStatus !== "granted") {
if (nextMicStatus !== "granted" || (requiresAccessibility && nextAccStatus !== "granted")) {
return;
}
@ -221,12 +259,25 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
onComplete();
};
const canContinue = micStatus === "granted" && accStatus === "granted";
const platform = runtimeInfo?.platform ?? detectDesktopPlatform();
const requiresAccessibility = platform === "macos";
// In dev mode the binary lives in the build target dir (e.g. /Volumes/...),
// which is not /Applications but that's expected, so skip the warning.
// which is not /Applications - but that's expected, so skip the warning.
const shouldShowInstallWarning = import.meta.env.DEV
? false
: Boolean(runtimeInfo?.shouldMoveToApplications);
const pastePermissionTitle = requiresAccessibility ? "Универсальный доступ" : "Вставка текста";
const pastePermissionDescription = requiresAccessibility
? "Нужен для глобальной горячей клавиши и вставки текста."
: platform === "linux"
? "Talkis использует буфер обмена и Ctrl+V. В некоторых Wayland/X11 окружениях автоматическая вставка может быть ограничена."
: "Talkis использует буфер обмена и Ctrl+V. Отдельное системное разрешение обычно не требуется.";
const pastePermissionHelpText = shouldShowInstallWarning
? `Приложение запущено не из Applications: ${runtimeInfo?.bundlePath ?? "-"}`
: platform === "linux"
? "Если вставка не сработает, скопированный текст останется в буфере обмена и его можно вставить вручную."
: undefined;
const canContinue = micStatus === "granted" && (!requiresAccessibility || accStatus === "granted");
const canCompleteOnboarding = canContinue && !shouldShowInstallWarning;
return (
@ -328,9 +379,9 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
<PermissionRow
icon={<Keyboard size={16} strokeWidth={1.8} />}
title="Универсальный доступ"
description="Нужен для глобальной горячей клавиши и вставки текста."
status={shouldShowInstallWarning ? "denied" : accStatus}
title={pastePermissionTitle}
description={pastePermissionDescription}
status={requiresAccessibility && shouldShowInstallWarning ? "denied" : requiresAccessibility ? accStatus : "granted"}
onAction={() => {
if (shouldShowInstallWarning) {
void openPath("/Applications");
@ -339,9 +390,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
void handleAccessibilityRequest();
}}
helpText={shouldShowInstallWarning
? `Приложение запущено не из Applications: ${runtimeInfo?.bundlePath ?? "—"}`
: undefined}
helpText={pastePermissionHelpText}
/>
</div>
@ -361,7 +410,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
<AlertCircle size={14} style={{ color: "var(--text-low)", flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.6 }}>
{micStatus === "denied"
? "Если микрофон был отклонен, откройте Системные настройки → Конфиденциальность → Микрофон и включите Talkis."
? microphoneHelpText(platform)
: shouldShowInstallWarning
? "После перемещения приложения в Applications откройте его заново."
: "macOS применяет доступ не мгновенно. После изменения настройки вернитесь в приложение."}
@ -376,7 +425,7 @@ export function PermissionScreen({ onComplete }: PermissionScreenProps) {
? "Сначала запустите из Applications."
: canContinue
? "Все доступы выданы."
: "Выдайте оба разрешения для продолжения."}
: requiresAccessibility ? "Выдайте оба разрешения для продолжения." : "Разрешите микрофон для продолжения."}
</div>
<button
onClick={handleContinue}

View file

@ -44,6 +44,21 @@ import xAiAvatar from "../../../assets/adapters/xai.png";
const IS_DEV = import.meta.env.DEV;
type LocalRuntimeKind = "whisper" | "nvidia" | "qwen";
type DesktopPlatform = "macos" | "windows" | "linux" | "unknown";
function detectDesktopPlatform(): DesktopPlatform {
if (typeof navigator === "undefined") {
return "unknown";
}
const value = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
if (value.includes("mac")) return "macos";
if (value.includes("win")) return "windows";
if (value.includes("linux") || value.includes("x11")) return "linux";
return "unknown";
}
const LOCAL_RUNTIME_ENDPOINTS: Record<LocalRuntimeKind, string> = {
whisper: "http://127.0.0.1:8000",
@ -942,6 +957,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
const hasActiveSubscription = cloudProfile?.subscription.active === true;
const isCloudMode = !settings.useOwnKey;
const isCustom = settings.provider === "custom";
const desktopPlatform = detectDesktopPlatform();
const activeModelMode: "cloud" | "api" | "local" = isCloudMode ? "cloud" : isCustom ? "local" : "api";
const isApiMode = activeModelMode === "api";
const isLocalMode = activeModelMode === "local";
@ -1328,7 +1344,8 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
const getLocalModelStatus = (model: LocalModelOption) => {
const actionState = localModelActionStates[model.id];
const cachedState = settings.localModels?.[model.id];
const isRuntimeReady = model.runtimeReady === true;
const isPlatformSupported = model.runtimeKind === "whisper" || desktopPlatform === "macos";
const isRuntimeReady = model.runtimeReady === true && isPlatformSupported;
const isInstalled = isRuntimeReady && (localInstalledModelSet.has(model.model) || cachedState?.status === "downloaded");
const isSelected = localSttTargetModel === model.model && isInstalled;
@ -1342,7 +1359,9 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
: `${runtimeName} runtime-слот подготовлен, но движок еще не встроен в сборку.`,
status: "unsupported" as const,
color: "var(--text-low)",
message: model.unavailableReason || `${runtimeName} sidecar запускается отдельно от Whisper, но скачивание и распознавание для этой линейки будут включены после подключения локального engine.`,
message: !isPlatformSupported
? "Эта локальная модель пока доступна только на macOS. Для Windows и Linux оставлен Whisper runtime."
: model.unavailableReason || `${runtimeName} sidecar запускается отдельно от Whisper, но скачивание и распознавание для этой линейки будут включены после подключения локального engine.`,
isInstalled: false,
isSelected: false,
};
@ -1913,7 +1932,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
const isExpanded = expandedLocalModel === model.id;
const modelStatus = getLocalModelStatus(model);
const modelActionState = localModelActionStates[model.id];
const isRuntimeReady = model.runtimeReady === true;
const isRuntimeReady = getLocalModelStatus(model).status !== "unsupported" && model.runtimeReady === true;
const isDownloaded = modelStatus.isInstalled;
const isModelBusy = modelStatus.status === "installing" || modelStatus.status === "deleting";
const isInstallDisabled = isModelBusy || !isRuntimeReady;