diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e8ae93e..183864d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -48,12 +48,53 @@ jobs:
working-directory: desktop
- run: npx tauri build --bundles ${{ matrix.bundle }}
working-directory: desktop
+ - name: Package portable Windows app
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $release = (Resolve-Path 'desktop/target/release').Path
+ $portable = "$release/bundle/portable/Efir-Windows-x64"
+ New-Item -ItemType Directory -Force -Path $portable | Out-Null
+ Copy-Item "$release/efir.exe" $portable
+ Get-ChildItem $release -Filter '*.dll' | Copy-Item -Destination $portable
+ Copy-Item 'desktop/resources/web' "$portable/web" -Recurse
+ Copy-Item 'desktop/resources/speech' "$portable/speech" -Recurse
+ Copy-Item 'LICENSE' "$portable/LICENSE.txt"
+ $version = (Get-Content 'desktop/package.json' | ConvertFrom-Json).version
+ @'
+ Extract this whole folder, then run efir.exe. Keep web/ and speech/ beside it.
+ Windows 10/11 x64 and Microsoft WebView2 Runtime are required.
+ Vosk language models are selected separately in voice settings.
+ '@ | Set-Content "$portable/START.txt" -Encoding utf8
+ $stdout = "$release/portable-stdout.log"
+ $stderr = "$release/portable-stderr.log"
+ $app = Start-Process "$portable/efir.exe" -WorkingDirectory $portable -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ try {
+ $ready = $false
+ $diagnostic = 'No HTTP response yet'
+ for ($attempt = 0; $attempt -lt 30; $attempt++) {
+ if ($app.HasExited) { throw "Portable app exited: $($app.ExitCode)" }
+ try {
+ $info = Invoke-RestMethod 'http://127.0.0.1:8765/efir-local.json' -NoProxy -TimeoutSec 1
+ $diagnostic = $info | ConvertTo-Json -Compress
+ if ($info.local -eq $true -and $info.platform -eq 'windows' -and $info.version -eq $version) { $ready = $true; break }
+ } catch { $diagnostic = $_.Exception.Message }
+ Start-Sleep -Milliseconds 500
+ }
+ if (-not $ready) { throw "Portable app did not start its local server: $diagnostic" }
+ } finally {
+ Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object OwningProcess -eq $app.Id | Format-Table LocalAddress, LocalPort, OwningProcess
+ Get-Content $stdout, $stderr -ErrorAction SilentlyContinue
+ if (-not $app.HasExited) { Stop-Process -Id $app.Id -Force }
+ }
+ Compress-Archive -Path $portable -DestinationPath "$release/bundle/portable/Efir-Windows-x64.zip"
- uses: actions/upload-artifact@v4
with:
name: Efir-${{ matrix.runtime }}
path: |
desktop/target/release/bundle/nsis/*.exe
desktop/target/release/bundle/deb/*.deb
+ desktop/target/release/bundle/portable/*.zip
if-no-files-found: error
android:
runs-on: ubuntu-24.04
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 1d40b23..2b973dd 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -17,8 +17,8 @@ android {
applicationId = "app.efir.android"
minSdk = 26
targetSdk = 35
- versionCode = 6
- versionName = "1.3.2"
+ versionCode = 7
+ versionName = "1.4.0"
testInstrumentationRunner = "app.efir.android.EfirTestRunner"
}
sourceSets["main"].assets.srcDir(layout.buildDirectory.dir("generated/assets"))
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 8b1d2e3..638510c 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -1089,7 +1089,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "efir-desktop"
-version = "1.3.2"
+version = "1.4.0"
dependencies = [
"axum",
"cpal",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index acbb799..24627e8 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "efir-desktop"
-version = "1.3.2"
+version = "1.4.0"
edition = "2021"
license = "MIT"
description = "Free teleprompter with remote control over local Wi-Fi"
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 4e7a4c8..e064a94 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "efir-desktop",
- "version": "1.3.2",
+ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "efir-desktop",
- "version": "1.3.2",
+ "version": "1.4.0",
"devDependencies": {
"@tauri-apps/cli": "2.12.0"
}
diff --git a/desktop/package.json b/desktop/package.json
index 21b7cbd..d8ce184 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "efir-desktop",
- "version": "1.3.2",
+ "version": "1.4.0",
"private": true,
"scripts": {
"dev": "tauri dev",
diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json
index cff320e..bf12c28 100644
--- a/desktop/tauri.conf.json
+++ b/desktop/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Efir",
- "version": "1.3.2",
+ "version": "1.4.0",
"identifier": "app.efir.desktop",
"build": {
"frontendDist": "bootstrap"
diff --git a/docs/development.md b/docs/development.md
index c1bc6d4..c9e3edd 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -87,10 +87,15 @@ export JAVA_HOME=/path/to/jdk17
export ANDROID_HOME=/path/to/android-sdk
python3 script/build_android.py --debug --test
python3 script/build_android.py --local-sign --output ./work/releases/Efir-Android.apk
+# Reuse a key stored outside the checkout:
+python3 script/build_android.py --local-sign --signing-dir /path/to/private/android \
+ --output ./work/releases/Efir-Android.apk
```
`--local-sign` creates or reuses a key under `.private/android/`. Back up that
-folder: updates must keep the same signing key. Passwords are passed through
+folder: updates must keep the same signing key. `--signing-dir` selects a persistent
+private directory outside disposable checkouts. Keep both `efir-release.jks` and
+`signing.json` together. Passwords are passed through
environment variables, not command-line arguments. For an existing key, set
`EFIR_KEYSTORE`, `EFIR_STORE_PASSWORD`, `EFIR_KEY_ALIAS`, `EFIR_KEY_PASSWORD` and
omit `--local-sign`. Without a key, release output is explicitly named unsigned.
diff --git a/docs/releases/1.4.0.md b/docs/releases/1.4.0.md
new file mode 100644
index 0000000..dd2a65b
--- /dev/null
+++ b/docs/releases/1.4.0.md
@@ -0,0 +1,59 @@
+# Efir 1.4.0
+
+## What's new
+
+- Map keyboard, media-key and pedal remotes to prompting actions. Learn buttons
+ from browser Gamepad devices, save mappings and use held forward/backward
+ nudges. Editing text and using dialogs do not trigger prompting shortcuts.
+- Connect several readers to one native server. Each screen keeps its own name,
+ font size, margins, top/bottom padding, marker position and mirror settings.
+ Word-based synchronization follows the same passage across different wraps.
+- Select a screen preview or see the readers together in a grid. Previews use
+ each screen's real geometry. Format guides shade the extra area when readers
+ have different aspect ratios, including 16:10 and 16:9.
+- Reconnect through saved screen links, select the timing leader, and keep a
+ useful frozen preview when a reader disconnects.
+- Support the project through the localized GitHub star button on the home page.
+
+## Resource improvements
+
+- Stream native desktop HTTP assets with backpressure instead of loading a
+ complete response into memory for every download.
+- Reuse reader geometry, avoid repeated screen-list DOM writes and omit duplicate
+ display metadata from frequent messages while keeping the existing update cadence.
+- Stop Gamepad polling when the controller is hidden or unfocused. Stop the home
+ page demo when hidden, outside the viewport or using reduced motion.
+- Release cancelled Android WebSocket tasks, cache native desktop translations,
+ and avoid duplicate macOS speech status messages.
+
+See the [shared web audit](../performance-audit.md) and
+[native resource audit](../native-resource-audit.md) for measured scenarios and
+remaining device checks. These measurements do not establish battery-life gains.
+
+## Downloads and installation
+
+| Platform | File | Requirements |
+| --- | --- | --- |
+| macOS | `Efir-macOS.zip` | Apple Silicon or Intel, macOS 14+ |
+| Windows | `Efir-Windows-x64.zip` | Windows 10/11 x64, Microsoft WebView2 Runtime |
+| Linux x64 | `Efir-Linux-x64.deb` | Ubuntu 24.04+ or compatible dependencies |
+| Linux ARM64 | `Efir-Linux-arm64.deb` | Ubuntu 24.04+ or compatible dependencies |
+| Android | `Efir-Android.apk` | Android 8+ |
+
+Extract the entire Windows folder before launching `efir.exe`; keep `web/`,
+`speech/` and any accompanying DLLs together. Offline Vosk language models are
+selected separately in voice settings. An NSIS installer is also provided.
+
+The macOS app is ad hoc signed and not notarized. Follow the documented
+[macOS opening steps](../macos-distribution.md). Windows packages do not have an
+Authenticode signature.
+
+Android 1.4.0 starts a new release signing certificate because the original
+private key is unavailable. If 1.3.2 is installed, export scripts and settings,
+uninstall it, then install 1.4.0 and restore the export. Future APK updates will
+reuse the new key, which is kept outside the source checkout.
+
+`Efir-1.4.0-SHA256.txt` lists package checksums. Keyboard HID and browser Gamepad
+support depends on the signals the remote exposes; proprietary BLE, MIDI and
+vendor-app protocols are not automatically supported. Physical remote models
+still need individual checks.
diff --git a/mac/Info.plist b/mac/Info.plist
index 02c26dd..b05e424 100644
--- a/mac/Info.plist
+++ b/mac/Info.plist
@@ -26,9 +26,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.2
+ 1.4.0
CFBundleVersion
- 6
+ 7
NSAppTransportSecurity
NSAllowsArbitraryLoadsInWebContent
diff --git a/mac/project.yml b/mac/project.yml
index a68d207..7aac062 100644
--- a/mac/project.yml
+++ b/mac/project.yml
@@ -33,8 +33,8 @@ targets:
path: Info.plist
properties:
CFBundleDisplayName: Эфир
- CFBundleShortVersionString: '1.3.2'
- CFBundleVersion: '6'
+ CFBundleShortVersionString: '1.4.0'
+ CFBundleVersion: '7'
CFBundleDevelopmentRegion: ru
CFBundleLocalizations: ['ru', 'en', 'es', 'zh-Hans', 'hi', 'ar']
NSLocalNetworkUsageDescription: Эфир раздаёт суфлёр устройствам в вашей локальной сети.
diff --git a/script/build_android.py b/script/build_android.py
index ed824fc..95034a4 100644
--- a/script/build_android.py
+++ b/script/build_android.py
@@ -12,8 +12,8 @@
ROOT = Path(__file__).resolve().parent.parent
-def local_signing(env):
- private = ROOT / '.private/android'
+def local_signing(env, directory=None):
+ private = directory.expanduser().resolve() if directory else ROOT / '.private/android'
private.mkdir(parents=True, exist_ok=True)
private.chmod(0o700)
credentials = private / 'signing.json'
@@ -21,7 +21,7 @@ def local_signing(env):
if credentials.exists():
config = json.loads(credentials.read_text())
if not key.exists():
- raise SystemExit('Signing key is missing; restore .private/android from your backup.')
+ raise SystemExit(f'Signing key is missing; restore {private} from your backup.')
else:
if key.exists():
raise SystemExit('Signing password is missing; restore signing.json from your backup.')
@@ -48,11 +48,14 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--debug', action='store_true')
parser.add_argument('--local-sign', action='store_true')
+ parser.add_argument('--signing-dir', type=Path, help='Persistent private key directory (requires --local-sign)')
parser.add_argument('--test', action='store_true', help='Also build the instrumentation APK')
parser.add_argument('--output', type=Path)
args = parser.parse_args()
if args.test and not args.debug:
parser.error('--test requires --debug')
+ if args.signing_dir and not args.local_sign:
+ parser.error('--signing-dir requires --local-sign')
env = dict(os.environ)
tools = ROOT / 'work/android-tools'
@@ -63,7 +66,7 @@ def main():
if not env.get('ANDROID_HOME') and (tools / 'sdk').exists():
env['ANDROID_HOME'] = str(tools / 'sdk')
if args.local_sign:
- local_signing(env)
+ local_signing(env, args.signing_dir)
kind = 'debug' if args.debug else 'release'
tasks = ['assembleDebug', 'lintDebug'] if args.debug else ['assembleRelease', 'lintRelease']
diff --git a/script/build_site.py b/script/build_site.py
index 5c79648..e1a29d1 100644
--- a/script/build_site.py
+++ b/script/build_site.py
@@ -8,6 +8,7 @@
from string import Template
ROOT = Path(__file__).resolve().parent.parent
+VERSION = json.loads((ROOT / "desktop/package.json").read_text())["version"]
OUT = ROOT / "work" / "site"
BASE = "https://fedyalight.github.io/efir"
REPO = "https://github.com/FedyaLight/efir"
@@ -49,7 +50,7 @@ def build():
canonical = f"{BASE}/{page_path(language)}"
data = {key: html.escape(value, quote=True) for key, value in text.items() if isinstance(value, str)}
data.update(lang=locale, direction="rtl" if language == "ar" else "ltr", prefix=prefix,
- canonical=canonical, base=BASE, repo=REPO)
+ canonical=canonical, base=BASE, repo=REPO, version=VERSION)
data["alternates"] = "\n ".join(
f''
for code, tag in LOCALES.items()
@@ -78,7 +79,7 @@ def build():
"@context": "https://schema.org", "@type": "SoftwareApplication", "name": "Efir",
"alternateName": "Эфир", "url": canonical, "description": text["description"],
"applicationCategory": "MultimediaApplication", "operatingSystem": "macOS, Windows, Linux, Android, Web",
- "softwareVersion": "1.3.2", "inLanguage": list(LOCALES.values()),
+ "softwareVersion": VERSION, "inLanguage": list(LOCALES.values()),
"license": f"{REPO}/blob/main/LICENSE", "sameAs": REPO,
"downloadUrl": f"{REPO}/releases/latest", "image": f"{BASE}/assets/social.png",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
diff --git a/site/template.html b/site/template.html
index 60abb55..7414097 100644
--- a/site/template.html
+++ b/site/template.html
@@ -38,9 +38,9 @@ $heading
$start_title
$steps
$android
-
+
-
+