104 Commits

Author SHA1 Message Date
GitHub Actions
c8b57ba217 chore: bump version to 4.11.168 [skip ci] 2026-07-24 06:52:49 +00:00
8d8809d0c7 Bump cinny: open DMs while space parent maps catch up.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 14s
Build / build-linux (push) Successful in 2m44s
Build / build-windows (push) Successful in 3m45s
Build / finalize-release (push) Successful in 4s
2026-07-24 16:52:35 +10:00
GitHub Actions
55a0f4f566 chore: bump version to 4.11.167 [skip ci] 2026-07-24 06:24:58 +00:00
f488f32d88 Fix PowerShell mangling GitHub upload URLs with ?name=.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 13s
Build / build-linux (push) Successful in 2m45s
Build / build-windows (push) Successful in 3m43s
Build / finalize-release (push) Successful in 4s
2026-07-24 16:24:44 +10:00
GitHub Actions
9154929de9 chore: bump version to 4.11.166 [skip ci] 2026-07-24 06:20:05 +00:00
c2cc0d5ed5 Fix PowerShell origin URL trimming for submodule checkout.
Some checks failed
Build / increment-version (push) Successful in 13s
Build / prepare-release (push) Successful in 13s
Build / build-linux (push) Successful in 2m44s
Build / build-windows (push) Failing after 3m22s
Build / finalize-release (push) Successful in 8s
2026-07-24 16:19:51 +10:00
GitHub Actions
aa38348fdd chore: bump version to 4.11.165 [skip ci] 2026-07-24 06:17:38 +00:00
02ab544a37 Fix CI Windows upload shell and submodule clone host.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 14s
Build / build-windows (push) Failing after 11s
Build / finalize-release (push) Has been cancelled
Build / build-linux (push) Has been cancelled
Use PowerShell + curl.exe on the Windows runner (no WSL), and clone cinny via a relative/origin-derived URL so act runners do not hairpin through public HTTPS.
2026-07-24 16:17:23 +10:00
GitHub Actions
3707a6d38a chore: bump version to 4.11.164 [skip ci] 2026-07-24 06:09:58 +00:00
fe1f4abf2d Bypass flaky Actions artifacts for AppImage/exe uploads.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 13s
Build / build-windows (push) Failing after 3m27s
Build / finalize-release (push) Has been cancelled
Build / build-linux (push) Has been cancelled
Publish build outputs straight to Gitea/GitHub releases via the REST API with no HTTP timeout, since Gitea's artifact pipeline keeps timing out on ~130MB binaries.
2026-07-24 16:09:44 +10:00
147389724e Point cinny submodule at git.ruv.wtf. 2026-07-24 16:02:45 +10:00
GitHub Actions
d4d4c73c2b chore: bump version to 4.11.163 [skip ci] 2026-07-24 06:01:21 +00:00
73fbc1c77a Bump cinny: hide Jump to Latest when scrolled to live bottom.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / build-windows (push) Successful in 3m30s
Build / build-linux (push) Failing after 7m44s
Build / create-release (push) Has been cancelled
2026-07-24 16:01:06 +10:00
GitHub Actions
3dc732521f chore: bump version to 4.11.162 [skip ci] 2026-07-23 05:18:40 +00:00
b76aa3fe4a Add mock auto-updater in dev and title-bar download progress.
All checks were successful
Build / increment-version (push) Successful in 11s
Build / build-linux (push) Successful in 1m54s
Build / build-windows (push) Successful in 3m36s
Build / create-release (push) Successful in 1m13s
2026-07-23 15:18:25 +10:00
GitHub Actions
c0d1e87b8d chore: bump version to 4.11.161 [skip ci] 2026-07-23 05:14:07 +00:00
04d4026e78 Fix Linux SSO return via paarrot://desktop and protocol handler repair.
Some checks failed
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m55s
Build / build-windows (push) Successful in 3m35s
Build / create-release (push) Has been cancelled
2026-07-23 15:13:54 +10:00
GitHub Actions
cfbc59d6a9 chore: bump version to 4.11.160 [skip ci] 2026-07-23 04:58:11 +00:00
dfe42fa942 Sync cinny: remove editor autocorrect workaround.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m54s
Build / build-windows (push) Successful in 3m35s
Build / create-release (push) Successful in 1m12s
2026-07-23 14:57:58 +10:00
GitHub Actions
6a8397425e chore: bump version to 4.11.159 [skip ci] 2026-07-23 04:52:37 +00:00
641ec26142 Sync cinny: Shared Media drawer and same-room navigation fixes.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m59s
Build / build-windows (push) Successful in 3m47s
Build / create-release (push) Successful in 1m12s
2026-07-23 14:52:23 +10:00
GitHub Actions
1eaf246806 chore: bump version to 4.11.158 [skip ci] 2026-07-22 14:24:31 +00:00
0e97e4d3b6 Sync cinny: Stationery and Stationery Dark themes.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m58s
Build / build-windows (push) Successful in 3m37s
Build / create-release (push) Successful in 1m13s
2026-07-23 00:24:17 +10:00
GitHub Actions
1636a8d0c6 chore: bump version to 4.11.157 [skip ci] 2026-07-22 10:29:14 +00:00
ac236750aa Sync cinny: fix jumbo emoji regex for production builds.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 1m52s
Build / build-windows (push) Successful in 3m40s
Build / create-release (push) Successful in 1m12s
2026-07-22 20:29:00 +10:00
GitHub Actions
7e747e6c36 chore: bump version to 4.11.156 [skip ci] 2026-07-22 10:14:27 +00:00
1b2cae5b12 Sync cinny: fix jumbo emoji in production builds.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m56s
Build / build-windows (push) Successful in 3m42s
Build / create-release (push) Successful in 1m11s
2026-07-22 20:14:15 +10:00
GitHub Actions
76921c4e79 chore: bump version to 4.11.155 [skip ci] 2026-07-22 10:00:52 +00:00
688efff7c9 Sync cinny submodule and stop Vite from opening a browser on dev.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m55s
Build / build-windows (push) Successful in 3m36s
Build / create-release (push) Successful in 1m13s
Pull in timeline scroll memory, media height reservation, avatar caching, jumbo emoji, and Unicode 15 emoji font coverage.
2026-07-22 20:00:38 +10:00
GitHub Actions
9bc377b62a chore: bump version to 4.11.154 [skip ci] 2026-07-22 09:11:11 +00:00
836ccce7a3 Revert cinny to pre-jumbo emoji baseline.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m57s
Build / build-windows (push) Successful in 3m37s
Build / create-release (push) Successful in 1m13s
Roll back submodule to 154f4dfd before jumbo emoji and related styling regressions.
2026-07-22 19:10:58 +10:00
GitHub Actions
34c8e6e38c chore: bump version to 4.11.153 [skip ci] 2026-07-22 09:03:00 +00:00
e69bda3aa6 Bump cinny: fix production chat text color in Electron builds.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 1m51s
Build / build-windows (push) Successful in 3m43s
Build / create-release (push) Successful in 1m13s
2026-07-22 19:02:46 +10:00
GitHub Actions
5d55e5d826 chore: bump version to 4.11.152 [skip ci] 2026-07-22 08:37:39 +00:00
b5902b9fc3 Bump cinny: fix production CSS loading in Electron build.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m51s
Build / build-windows (push) Successful in 3m34s
Build / create-release (push) Successful in 1m12s
2026-07-22 18:37:26 +10:00
GitHub Actions
f60cf0bd3c chore: bump version to 4.11.151 [skip ci] 2026-07-22 08:21:32 +00:00
83dec0ef54 Bump cinny: fix black chat pane and jumbo emoji rendering.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 1m50s
Build / build-windows (push) Successful in 3m35s
Build / create-release (push) Successful in 1m12s
2026-07-22 18:21:18 +10:00
GitHub Actions
40a76f02cd chore: bump version to 4.11.150 [skip ci] 2026-07-22 08:02:08 +00:00
3def3cc456 Bump cinny: instant room switches, perf cleanup, members drawer prefs.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m53s
Build / build-windows (push) Successful in 3m42s
Build / create-release (push) Successful in 1m13s
2026-07-22 18:01:53 +10:00
GitHub Actions
30bd67d43a chore: bump version to 4.11.149 [skip ci] 2026-07-22 06:16:20 +00:00
ffeccee680 Bump cinny: media placeholders, avatar blob cache, jumbo emoji.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / build-linux (push) Successful in 1m58s
Build / build-windows (push) Successful in 3m50s
Build / create-release (push) Successful in 1m13s
2026-07-22 14:42:19 +10:00
GitHub Actions
facbeee024 chore: bump version to 4.11.148 [skip ci] 2026-07-21 06:21:51 +00:00
09102c7903 Bump cinny: fix jumbo emoji actually rendering at 100px.
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 1m55s
Build / build-windows (push) Successful in 5m42s
Build / create-release (push) Successful in 1m13s
2026-07-21 16:21:37 +10:00
GitHub Actions
fa33ec7ac8 chore: bump version to 4.11.147 [skip ci] 2026-07-21 05:56:18 +00:00
c44f5cf14a Bump cinny: fix jumbo standalone emoji sizing.
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 1m57s
Build / build-windows (push) Successful in 5m21s
Build / create-release (push) Successful in 1m14s
2026-07-21 15:56:03 +10:00
GitHub Actions
27793d7c4f chore: bump version to 4.11.146 [skip ci] 2026-07-21 05:38:44 +00:00
32b2902ef7 Bump cinny: fix jumbo emoji vanilla-extract build.
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m2s
Build / build-windows (push) Successful in 3m33s
Build / create-release (push) Successful in 1m13s
2026-07-21 15:38:29 +10:00
GitHub Actions
5cdb318854 chore: bump version to 4.11.145 [skip ci] 2026-07-21 05:34:16 +00:00
a28f05bb88 Bump cinny: theme polish, jumbo emoji, and Windows autocorrect fix.
Some checks failed
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Failing after 44s
Build / build-windows (push) Failing after 1m4s
Build / create-release (push) Has been skipped
2026-07-21 15:34:00 +10:00
GitHub Actions
cecfe73b2e chore: bump version to 4.11.144 [skip ci] 2026-07-14 17:31:07 +00:00
b458c7918c chore: bump cinny submodule for emoji font spacing fix
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m3s
Build / build-windows (push) Successful in 5m10s
Build / create-release (push) Successful in 1m13s
2026-07-15 03:30:53 +10:00
GitHub Actions
d8660f4822 chore: bump version to 4.11.143 [skip ci] 2026-07-12 16:40:22 +00:00
dad83773a6 Bump cinny: Twemoji digit fix and Slate emoji focus crash
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 1m55s
Build / build-windows (push) Successful in 4m48s
Build / create-release (push) Successful in 1m12s
2026-07-13 02:40:08 +10:00
GitHub Actions
d2d35de46b chore: bump version to 4.11.142 [skip ci] 2026-07-12 16:25:25 +00:00
c752ec0b38 Bump cinny: fix noise/echo cancellation settings
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 1m57s
Build / build-windows (push) Successful in 5m5s
Build / create-release (push) Successful in 1m13s
2026-07-13 02:25:10 +10:00
GitHub Actions
28752eb56e chore: bump version to 4.11.141 [skip ci] 2026-07-12 08:15:59 +00:00
4f243a0fcf Bump cinny: fix vanilla-extract presence CSS build failure.
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 1m57s
Build / build-windows (push) Successful in 4m48s
Build / create-release (push) Successful in 1m13s
2026-07-12 18:15:44 +10:00
GitHub Actions
9bce616f26 chore: bump version to 4.11.140 [skip ci] 2026-07-11 22:19:18 +00:00
f16ab73c9a Allow git deps in npm and bump cinny submodule.
Some checks failed
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Failing after 42s
Build / build-windows (push) Failing after 1m47s
Build / create-release (push) Has been skipped
2026-07-12 08:19:03 +10:00
GitHub Actions
c29a15e073 chore: bump version to 4.11.139 [skip ci] 2026-07-11 21:52:03 +00:00
f5fe61e3eb hidden shit, and online status bars, search settings
Some checks failed
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Failing after 42s
Build / build-windows (push) Failing after 1m29s
Build / create-release (push) Has been skipped
2026-07-12 07:51:47 +10:00
GitHub Actions
a1a752b0fb chore: bump version to 4.11.138 [skip ci] 2026-07-11 21:15:56 +00:00
7769250875 Removed invalid channels
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 1m56s
Build / build-windows (push) Successful in 4m36s
Build / create-release (push) Successful in 1m13s
2026-07-12 07:15:28 +10:00
GitHub Actions
b3af9f3185 chore: bump version to 4.11.137 [skip ci] 2026-07-10 11:40:45 +00:00
7906326790 update base
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 1m56s
Build / build-windows (push) Successful in 4m56s
Build / create-release (push) Successful in 1m13s
2026-07-10 21:40:27 +10:00
GitHub Actions
3b4babb9d7 chore: bump version to 4.11.136 [skip ci] 2026-07-08 18:22:44 +00:00
4b284ec747 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m0s
Build / build-windows (push) Successful in 3m28s
Build / create-release (push) Successful in 1m12s
2026-07-09 04:22:28 +10:00
e1b4cfb9c8 chore: bump cinny submodule for compact nav and back routing fixes 2026-07-09 04:22:14 +10:00
GitHub Actions
c0266903ec chore: bump version to 4.11.135 [skip ci] 2026-07-08 16:35:39 +00:00
81964d6f14 chore: bump cinny submodule for React Router 7 routing fixes
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 1m57s
Build / build-windows (push) Successful in 4m33s
Build / create-release (push) Successful in 1m15s
2026-07-09 02:35:18 +10:00
GitHub Actions
47e025cf55 chore: bump version to 4.11.134 [skip ci] 2026-07-08 15:56:06 +00:00
b400070ad0 chore: bump cinny submodule for sync recovery and toolchain upgrades
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m19s
Build / build-windows (push) Successful in 3m48s
Build / create-release (push) Successful in 1m14s
2026-07-09 01:55:21 +10:00
529694ce66 chore: update dependencies and improve external link handling in main.js 2026-07-09 01:52:52 +10:00
GitHub Actions
791c3ab4df chore: bump version to 4.11.133 [skip ci] 2026-07-06 06:07:10 +00:00
de8d9db795 chore: update subproject commit reference in cinny to indicate dirty state
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m52s
Build / build-windows (push) Successful in 4m32s
Build / create-release (push) Successful in 1m15s
2026-07-06 16:06:52 +10:00
GitHub Actions
ffaf9ff66a chore: bump version to 4.11.132 [skip ci] 2026-07-06 05:35:44 +00:00
f0891ba4cc Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 2m11s
Build / build-windows (push) Successful in 3m45s
Build / create-release (push) Successful in 1m11s
2026-07-06 15:35:28 +10:00
627b117119 chore: update subproject commit reference in cinny 2026-07-06 15:35:27 +10:00
GitHub Actions
b6c6659a5a chore: bump version to 4.11.131 [skip ci] 2026-07-05 17:29:38 +00:00
d7a73afebc Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 2m16s
Build / build-windows (push) Successful in 3m53s
Build / create-release (push) Successful in 1m7s
2026-07-06 03:29:23 +10:00
e469d51ba4 fix: improve navigation handler in main.js to better manage external links 2026-07-06 03:29:19 +10:00
GitHub Actions
6ba03277e5 chore: bump version to 4.11.130 [skip ci] 2026-07-05 15:30:44 +00:00
fdb2dca794 fix: update subproject status to dirty in cinny
All checks were successful
Build / increment-version (push) Successful in 13s
Build / build-linux (push) Successful in 2m12s
Build / build-windows (push) Successful in 4m53s
Build / create-release (push) Successful in 1m11s
2026-07-06 01:30:28 +10:00
GitHub Actions
8797bb5a22 chore: bump version to 4.11.129 [skip ci] 2026-07-05 15:07:08 +00:00
4d154fd4f3 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 36s
Build / build-linux (push) Successful in 2m15s
Build / build-windows (push) Successful in 4m8s
Build / create-release (push) Successful in 1m12s
2026-07-06 01:06:28 +10:00
42e8fb71ef fix: mark subproject as dirty in cinny 2026-07-06 01:06:26 +10:00
GitHub Actions
ff2c284984 chore: bump version to 4.11.128 [skip ci] 2026-05-24 15:01:27 +00:00
75be84e472 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m1s
Build / build-windows (push) Successful in 4m22s
Build / create-release (push) Successful in 1m10s
2026-05-25 01:01:10 +10:00
a3c91b306a fix: remove element protocol support and update related functionality 2026-05-25 01:01:06 +10:00
GitHub Actions
20edc293bb chore: bump version to 4.11.127 [skip ci] 2026-05-24 14:34:35 +00:00
6803eac8fb Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 14s
Build / build-linux (push) Successful in 2m4s
Build / build-windows (push) Successful in 5m5s
Build / create-release (push) Successful in 1m10s
2026-05-25 00:34:19 +10:00
fa5592f1e4 feat: enhance protocol handling and diagnostics for Paarrot and Element URLs 2026-05-25 00:34:15 +10:00
GitHub Actions
78601be6f4 chore: bump version to 4.11.126 [skip ci] 2026-05-24 14:14:31 +00:00
c940bdc78c feat: add support for element:// deep links and register protocol handler
All checks were successful
Build / increment-version (push) Successful in 15s
Build / build-linux (push) Successful in 2m11s
Build / build-windows (push) Successful in 5m35s
Build / create-release (push) Successful in 1m11s
2026-05-25 00:14:09 +10:00
GitHub Actions
9471fe2f5d chore: bump version to 4.11.125 [skip ci] 2026-05-17 07:08:14 +00:00
57f7fe7086 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 11s
Build / build-linux (push) Successful in 1m59s
Build / build-windows (push) Successful in 4m46s
Build / create-release (push) Successful in 1m8s
2026-05-17 17:08:00 +10:00
a2364f2622 fix: mark subproject as dirty in cinny 2026-05-17 17:07:58 +10:00
GitHub Actions
df2220581d chore: bump version to 4.11.124 [skip ci] 2026-05-17 06:53:59 +00:00
198eb0fe04 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
All checks were successful
Build / increment-version (push) Successful in 11s
Build / build-linux (push) Successful in 1m59s
Build / build-windows (push) Successful in 5m24s
Build / create-release (push) Successful in 1m18s
2026-05-17 16:53:45 +10:00
fff114abef fix: mark subproject as dirty in cinny 2026-05-17 16:53:41 +10:00
GitHub Actions
7013f1c22d chore: bump version to 4.11.123 [skip ci] 2026-05-17 06:51:55 +00:00
1dbc7d6cc4 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny-desktop
Some checks failed
Build / increment-version (push) Successful in 11s
Build / build-linux (push) Failing after 47s
Build / build-windows (push) Failing after 1m37s
Build / create-release (push) Has been skipped
2026-05-17 16:51:43 +10:00
f93f109d4a fix: mark subproject as dirty in cinny 2026-05-17 16:51:40 +10:00
GitHub Actions
f707aa98f5 chore: bump version to 4.11.122 [skip ci] 2026-05-17 06:48:56 +00:00
11 changed files with 2444 additions and 2564 deletions

View File

@@ -8,6 +8,9 @@ on:
tags: tags:
- 'v*' - 'v*'
# Large AppImage/exe uploads time out on Gitea's actions artifact pipeline.
# Build jobs publish assets straight to Gitea/GitHub releases via the REST API.
jobs: jobs:
increment-version: increment-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -105,10 +108,101 @@ jobs:
echo "VERSION=${{ steps.decide.outputs.CURRENT_VERSION }}" >> $GITHUB_OUTPUT echo "VERSION=${{ steps.decide.outputs.CURRENT_VERSION }}" >> $GITHUB_OUTPUT
fi fi
prepare-release:
runs-on: ubuntu-latest
needs: increment-version
if: always() && needs.increment-version.outputs.should_build == 'true'
outputs:
version: ${{ steps.version.outputs.VERSION }}
tag: ${{ steps.version.outputs.TAG_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
run: git pull origin main
- name: Resolve version
id: version
run: |
if [ -n "${{ needs.increment-version.outputs.version }}" ]; then
VERSION="${{ needs.increment-version.outputs.version }}"
else
VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG_NAME=v$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Create and push tag
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
echo "Tag $TAG_NAME already exists"
else
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
git push origin "$TAG_NAME"
fi
- name: Ensure Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
API_BASE="${{ github.server_url }}/api/v1"
REPO="${{ github.repository }}"
RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true)
if [ -z "$RELEASE_ID" ]; then
echo "Creating Gitea release ${TAG_NAME}..."
RELEASE_ID=$(curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
fi
echo "Gitea release id: ${RELEASE_ID}"
- name: Ensure GitHub release
env:
GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.VERSION }}"
TAG_NAME="v${VERSION}"
GITHUB_REPO="Paarrot/Paarrot-Desktop"
RELEASE_ID=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true)
if [ -z "$RELEASE_ID" ]; then
echo "Creating GitHub release ${TAG_NAME}..."
RELEASE_ID=$(curl -fsS -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.id')
fi
echo "GitHub release id: ${RELEASE_ID}"
build-windows: build-windows:
runs-on: windows runs-on: windows
needs: increment-version needs: [increment-version, prepare-release]
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped') if: always() && needs.prepare-release.result == 'success'
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -120,16 +214,29 @@ jobs:
- name: Checkout submodules manually - name: Checkout submodules manually
shell: powershell shell: powershell
run: | run: |
$ErrorActionPreference = "Stop"
# Use the same host/scheme the runner already used for origin (not public HTTPS).
$origin = (git remote get-url origin).Trim()
$origin = $origin.TrimEnd('/')
if ($origin.EndsWith('.git')) { $origin = $origin.Substring(0, $origin.Length - 4) }
$base = $origin.Substring(0, $origin.LastIndexOf('/'))
$cinnyUrl = "$base/cinny.git"
Write-Host "Rewriting submodule URL to $cinnyUrl"
git config submodule.cinny.url $cinnyUrl
git submodule sync --recursive git submodule sync --recursive
if (git submodule update --init --recursive) { git submodule update --init --recursive
if ($LASTEXITCODE -eq 0) {
Write-Host "Submodules checked out at pinned commits." Write-Host "Submodules checked out at pinned commits."
exit 0 exit 0
} }
Write-Warning "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD." Write-Warning "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD."
git submodule deinit -f --all git submodule deinit -f --all
git config submodule.cinny.url $cinnyUrl
git submodule sync --recursive
git submodule update --init --recursive --remote git submodule update --init --recursive --remote
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Resolved submodule commit:" Write-Host "Resolved submodule commit:"
git -C cinny rev-parse HEAD git -C cinny rev-parse HEAD
@@ -162,17 +269,92 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
- name: Upload NSIS Installer (x64) - name: Upload Windows assets to releases
uses: actions/upload-artifact@v3 shell: powershell
with: env:
name: Paarrot-Setup-x64.exe GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
path: dist-electron/Paarrot-*-win-x64.exe GH_TOKEN: ${{ secrets.GHTOKEN }}
run: |
- name: Upload latest.yml $ErrorActionPreference = "Stop"
uses: actions/upload-artifact@v3 $TAG_NAME = "${{ needs.prepare-release.outputs.tag }}"
with: $API_BASE = "${{ github.server_url }}/api/v1"
name: latest.yml $REPO = "${{ github.repository }}"
path: dist-electron/latest.yml $GITHUB_REPO = "Paarrot/Paarrot-Desktop"
function Invoke-JsonCurl([string[]]$CurlArgs) {
$raw = & curl.exe @CurlArgs
if ($LASTEXITCODE -ne 0) { throw "curl failed: $($CurlArgs -join ' ')" }
return ($raw | ConvertFrom-Json)
}
$giteaRelease = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GITEA_TOKEN",
"$API_BASE/repos/$REPO/releases/tags/$TAG_NAME"
)
$ghRelease = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GH_TOKEN",
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG_NAME"
)
# GitHub returns upload_url like .../assets{?name,label}
$ghUploadUrl = [string]$ghRelease.upload_url
if ([string]::IsNullOrWhiteSpace($ghUploadUrl)) {
throw "GitHub release $($ghRelease.id) has no upload_url"
}
$ghUploadUrl = $ghUploadUrl -replace '\{\?[^}]*\}', ''
Write-Host "GitHub upload base: $ghUploadUrl"
function Upload-One([string]$File) {
$filename = [System.IO.Path]::GetFileName($File)
$sizeMb = [math]::Round((Get-Item $File).Length / 1MB, 1)
Write-Host "Uploading $filename (${sizeMb} MB)..."
$giteaAssets = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GITEA_TOKEN",
"$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets"
)
foreach ($asset in @($giteaAssets)) {
if ($asset.name -eq $filename) {
& curl.exe -fsS -X DELETE -H "Authorization: token $env:GITEA_TOKEN" `
"$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets/$($asset.id)" | Out-Null
}
}
& curl.exe -fsS --max-time 0 --retry 5 --retry-delay 10 `
-X POST `
-H "Authorization: token $env:GITEA_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$File" `
"$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets?name=$filename" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Gitea upload failed for $filename" }
Write-Host " Gitea: ok"
$ghAssets = Invoke-JsonCurl @(
"-fsS", "-H", "Authorization: token $env:GH_TOKEN",
"https://api.github.com/repos/$GITHUB_REPO/releases/$($ghRelease.id)/assets"
)
foreach ($asset in @($ghAssets)) {
if ($asset.name -eq $filename) {
& curl.exe -fsS -X DELETE -H "Authorization: token $env:GH_TOKEN" `
"https://api.github.com/repos/$GITHUB_REPO/releases/assets/$($asset.id)" | Out-Null
}
}
# Brace the variable — `$url?name=` is parsed as PowerShell's null-conditional.
$ghAssetUrl = "${ghUploadUrl}?name=$([uri]::EscapeDataString($filename))"
Write-Host " GitHub URL: $ghAssetUrl"
& curl.exe -fsS --max-time 0 --retry 5 --retry-delay 10 `
-X POST `
-H "Authorization: token $env:GH_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$File" `
$ghAssetUrl | Out-Null
if ($LASTEXITCODE -ne 0) { throw "GitHub upload failed for $filename" }
Write-Host " GitHub: ok"
}
Get-ChildItem -Path dist-electron -File |
Where-Object { $_.Name -like 'Paarrot-*-win-x64.exe' -or $_.Name -eq 'latest.yml' } |
ForEach-Object { Upload-One $_.FullName }
- name: List build output - name: List build output
shell: powershell shell: powershell
@@ -181,8 +363,8 @@ jobs:
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: increment-version needs: [increment-version, prepare-release]
if: always() && (needs.increment-version.outputs.should_build == 'true' || needs.increment-version.result == 'skipped') if: always() && needs.prepare-release.result == 'success'
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -194,6 +376,14 @@ jobs:
- name: Checkout submodules manually - name: Checkout submodules manually
run: | run: |
set -euo pipefail set -euo pipefail
# Use the same host/scheme the runner already used for origin (not public HTTPS).
ORIGIN_URL=$(git remote get-url origin)
ORIGIN_URL=${ORIGIN_URL%/}
ORIGIN_URL=${ORIGIN_URL%.git}
BASE_URL=${ORIGIN_URL%/*}
CINNY_URL="${BASE_URL}/cinny.git"
echo "Rewriting submodule URL to ${CINNY_URL}"
git config submodule.cinny.url "${CINNY_URL}"
git submodule sync --recursive git submodule sync --recursive
if git submodule update --init --recursive; then if git submodule update --init --recursive; then
@@ -202,7 +392,10 @@ jobs:
fi fi
echo "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD." echo "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD."
git submodule deinit -f --all git submodule deinit -f --all || true
rm -rf cinny .git/modules/cinny
git config submodule.cinny.url "${CINNY_URL}"
git submodule sync --recursive
git submodule update --init --recursive --remote git submodule update --init --recursive --remote
echo "Resolved submodule commit:" echo "Resolved submodule commit:"
@@ -233,208 +426,126 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
- name: Upload AppImage - name: Upload Linux assets to releases
uses: actions/upload-artifact@v3 env:
with: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Paarrot-Linux-x64.AppImage GH_TOKEN: ${{ secrets.GHTOKEN }}
path: dist-electron/Paarrot-*.AppImage run: |
set -euo pipefail
- name: Upload latest-linux.yml VERSION="${{ needs.prepare-release.outputs.version }}"
uses: actions/upload-artifact@v3 TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
with: API_BASE="${{ github.server_url }}/api/v1"
name: latest-linux.yml REPO="${{ github.repository }}"
path: dist-electron/latest-linux.yml GITHUB_REPO="Paarrot/Paarrot-Desktop"
create-release: GITEA_RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id')
GH_RELEASE_ID=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id')
GH_UPLOAD_URL=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
upload_one() {
local FILE="$1"
local FILENAME
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME} ($(du -h "$FILE" | cut -f1))..."
OLD_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets" \
| jq -r --arg n "$FILENAME" '.[] | select(.name == $n) | .id' || true)
if [ -n "${OLD_ID:-}" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets/${OLD_ID}" >/dev/null
fi
curl -fsS --max-time 0 --retry 5 --retry-delay 10 \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets?name=${FILENAME}" \
>/dev/null
echo " Gitea: ok"
OLD_GH=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}/assets" \
| jq -r --arg n "$FILENAME" '.[] | select(.name == $n) | .id' || true)
if [ -n "${OLD_GH:-}" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${OLD_GH}" >/dev/null
fi
curl -fsS --max-time 0 --retry 5 --retry-delay 10 \
-X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \
"${GH_UPLOAD_URL}?name=${FILENAME}" \
>/dev/null
echo " GitHub: ok"
}
shopt -s nullglob
for FILE in dist-electron/Paarrot-*.AppImage dist-electron/latest-linux.yml; do
upload_one "$FILE"
done
finalize-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [increment-version, build-windows, build-linux] needs: [increment-version, prepare-release, build-windows, build-linux]
if: always() && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success') if: always() && needs.prepare-release.result == 'success' && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success')
steps: steps:
- name: Checkout repository - name: Cleanup old Gitea releases
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Pull latest (after version bump)
if: github.ref == 'refs/heads/main'
run: git pull origin main
- name: Get version
id: version
run: |
# Use version from increment-version job if available, otherwise read from file
if [ -n "${{ needs.increment-version.outputs.version }}" ]; then
VERSION="${{ needs.increment-version.outputs.version }}"
else
VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1)
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Check if tag exists
id: tag_check
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "Tag v$VERSION does not exist"
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create and push tag
if: steps.tag_check.outputs.exists == 'false'
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag -a "v${{ steps.version.outputs.VERSION }}" -m "Release v${{ steps.version.outputs.VERSION }}"
git push origin "v${{ steps.version.outputs.VERSION }}"
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Prepare release files
run: |
mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.yml" \) -exec cp {} release-files/ \;
ls -la release-files/
- name: Create or Update Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" set -euo pipefail
TAG_NAME="v${VERSION}" TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
API_BASE="http://synbox.ruv.wtf:8418/api/v1" API_BASE="${{ github.server_url }}/api/v1"
REPO="litruv/cinny-desktop" REPO="${{ github.repository }}"
# Delete old releases (keep only current) echo "Cleaning up old Gitea releases (keeping ${TAG_NAME})..."
echo "Cleaning up old releases..." OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
OLD_RELEASES=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id')
"${API_BASE}/repos/${REPO}/releases" | jq -r '.[] | select(.tag_name != "'${TAG_NAME}'") | .id')
for OLD_ID in $OLD_RELEASES; do for OLD_ID in $OLD_RELEASES; do
echo "Deleting old release ${OLD_ID}..." [ -n "$OLD_ID" ] || continue
OLD_TAG=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ OLD_TAG=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${OLD_ID}" | jq -r '.tag_name') "${API_BASE}/repos/${REPO}/releases/${OLD_ID}" | jq -r '.tag_name')
echo "Deleting Gitea release ${OLD_ID} (${OLD_TAG})..."
# Delete release curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/${OLD_ID}" >/dev/null || true
"${API_BASE}/repos/${REPO}/releases/${OLD_ID}" if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
# Delete tag "${API_BASE}/repos/${REPO}/tags/${OLD_TAG}" >/dev/null || true
if [ -n "$OLD_TAG" ]; then
echo "Deleting old tag ${OLD_TAG}..."
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/tags/${OLD_TAG}"
fi fi
done done
# Check if release exists
RELEASE_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Creating new release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"${API_BASE}/repos/${REPO}/releases" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Release exists with ID: ${RELEASE_ID}"
# Delete existing assets
echo "Deleting existing assets..."
ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets" | jq -r '.[].id')
for ASSET_ID in $ASSETS; do
echo "Deleting asset ${ASSET_ID}..."
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
done
fi
# Upload new assets
echo "Uploading assets..."
for FILE in release-files/*; do
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME}..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo ""
done
echo "Release complete!"
- name: Create GitHub Release - name: Cleanup old GitHub releases
env: env:
GH_TOKEN: ${{ secrets.GHTOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }}
run: | run: |
VERSION="${{ steps.version.outputs.VERSION }}" set -euo pipefail
TAG_NAME="v${VERSION}" TAG_NAME="${{ needs.prepare-release.outputs.tag }}"
GITHUB_REPO="Paarrot/Paarrot-Desktop" GITHUB_REPO="Paarrot/Paarrot-Desktop"
# Delete old GitHub releases (keep only current) echo "Cleaning up old GitHub releases (keeping ${TAG_NAME})..."
echo "Cleaning up old GitHub releases..." OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
OLD_RELEASES=$(curl -s -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id')
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.[] | select(.tag_name != "'${TAG_NAME}'") | .id')
for OLD_ID in $OLD_RELEASES; do for OLD_ID in $OLD_RELEASES; do
echo "Deleting old GitHub release ${OLD_ID}..." [ -n "$OLD_ID" ] || continue
OLD_TAG=$(curl -s -H "Authorization: token ${GH_TOKEN}" \ OLD_TAG=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" | jq -r '.tag_name') "https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" | jq -r '.tag_name')
echo "Deleting GitHub release ${OLD_ID} (${OLD_TAG})..."
# Delete release curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
curl -s -X DELETE -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" >/dev/null || true
"https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then
curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \
# Delete tag "https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${OLD_TAG}" >/dev/null || true
if [ -n "$OLD_TAG" ]; then
echo "Deleting old GitHub tag ${OLD_TAG}..."
curl -s -X DELETE -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${OLD_TAG}"
fi fi
done done
# Check if release exists on GitHub echo "Release finalize complete for ${TAG_NAME}"
RELEASE_ID=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
echo "Creating new GitHub release for ${TAG_NAME}..."
RELEASE_ID=$(curl -s -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.id')
echo "Created GitHub release with ID: ${RELEASE_ID}"
else
echo "GitHub release exists with ID: ${RELEASE_ID}"
fi
# Upload assets to GitHub
echo "Uploading assets to GitHub..."
UPLOAD_URL=$(curl -s -H "Authorization: token ${GH_TOKEN}" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//')
for FILE in release-files/*; do
FILENAME=$(basename "$FILE")
echo "Uploading ${FILENAME} to GitHub..."
curl -s -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${FILE}" \
"${UPLOAD_URL}?name=${FILENAME}"
echo ""
done
echo "GitHub release complete!"

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "cinny"] [submodule "cinny"]
path = cinny path = cinny
url = http://synbox.ruv.wtf:8418/litruv/cinny.git url = ../cinny.git

3
.npmrc
View File

@@ -1 +1,2 @@
save-exact=true save-exact=true
allow-git=all

2
cinny

Submodule cinny updated: 40dc552c8d...99a294791e

View File

@@ -36,6 +36,12 @@
"filter": ["**/*"] "filter": ["**/*"]
} }
], ],
"protocols": [
{
"name": "Paarrot URL",
"schemes": ["paarrot"]
}
],
"linux": { "linux": {
"target": [ "target": [
{ {
@@ -51,6 +57,7 @@
"Name": "Paarrot", "Name": "Paarrot",
"GenericName": "Matrix Client", "GenericName": "Matrix Client",
"Comment": "A Matrix client built with Cinny", "Comment": "A Matrix client built with Cinny",
"MimeType": "x-scheme-handler/paarrot;",
"Categories": "Network;InstantMessaging;", "Categories": "Network;InstantMessaging;",
"Keywords": "matrix;chat;messaging;", "Keywords": "matrix;chat;messaging;",
"StartupWMClass": "paarrot" "StartupWMClass": "paarrot"

View File

@@ -1,10 +1,12 @@
const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron'); const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { exec } = require('child_process'); const { exec, execFile, execFileSync } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const Store = require('electron-store'); const Store = require('electron-store').default;
const open = require('open'); const openPkg = require('open');
const open = openPkg.default;
const openApps = openPkg.apps;
const PaarrotAPIServer = require('./api-server'); const PaarrotAPIServer = require('./api-server');
const https = require('https'); const https = require('https');
const http = require('http'); const http = require('http');
@@ -14,15 +16,107 @@ const { autoUpdater } = require('electron-updater');
// Development mode detection - MUST be declared before any code that might use it // Development mode detection - MUST be declared before any code that might use it
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
const VITE_DEV_SERVER = 'http://localhost:38347'; const VITE_DEV_SERVER = 'http://localhost:38347';
const VITE_DEV_PORT = '38347';
const PORT = 44548; const PORT = 44548;
function isLoopbackHost(hostname) {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
}
/**
* Open http(s) links in a real browser.
* On Linux, Electron's shell.openExternal / xdg-open often hit xdg-desktop-portal
* which can hand https:// to Discover instead of Firefox. Always launch a browser
* binary first; only fall back to the portal as a last resort.
*/
async function openExternalUrl(url) {
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) {
await shell.openExternal(url);
return;
}
if (process.platform === 'linux') {
const browserCandidates = [
openApps?.browser,
openApps?.firefox,
openApps?.chrome,
openApps?.edge,
openApps?.brave,
'firefox',
'firefox-esr',
'google-chrome',
'google-chrome-stable',
'chromium',
'chromium-browser',
'brave-browser',
'microsoft-edge',
'microsoft-edge-stable',
].filter(Boolean);
let lastError;
for (const name of browserCandidates) {
try {
await open(url, { app: { name } });
return;
} catch (err) {
lastError = err;
}
}
console.warn('[openExternalUrl] Browser candidates failed, falling back to shell.openExternal', lastError);
await shell.openExternal(url);
return;
}
try {
await open(url);
} catch (err) {
console.warn('[openExternalUrl] open() failed, falling back to shell.openExternal', err);
await shell.openExternal(url);
}
}
/** Keep Vite HMR / same-origin navigations inside Electron; only true externals go to the OS browser. */
function isAppOwnedUrl(targetUrl, currentUrl) {
try {
const next = new URL(targetUrl);
if (currentUrl) {
const current = new URL(currentUrl);
if (current.origin === next.origin) return true;
// localhost ↔ 127.0.0.1 on the same Vite port after a full reload
if (
isDev &&
isLoopbackHost(current.hostname) &&
isLoopbackHost(next.hostname) &&
current.port === next.port
) {
return true;
}
}
if (isDev && isLoopbackHost(next.hostname) && next.port === VITE_DEV_PORT) {
return true;
}
} catch {
return false;
}
return false;
}
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const store = new Store(); const store = new Store();
const PROTOCOL_SCHEME = 'paarrot';
let mainWindow = null; let mainWindow = null;
let tray = null; let tray = null;
let isQuitting = false; let isQuitting = false;
let apiServer = null; let apiServer = null;
let pendingDeepLink = null;
let protocolRegistrationState = {
before: false,
registerCall: false,
after: false,
error: null,
};
// Allow audio playback without requiring a prior user gesture (needed for notification sounds) // Allow audio playback without requiring a prior user gesture (needed for notification sounds)
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
@@ -115,16 +209,513 @@ const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
app.quit(); app.quit();
} else { } else {
app.on('second-instance', () => { app.on('second-instance', (event, argv) => {
// Someone tried to run a second instance, focus our window // Someone tried to run a second instance, focus our window
const deepLink = extractDeepLinkFromArgv(argv);
if (deepLink) {
pendingDeepLink = deepLink;
}
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore(); if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show(); mainWindow.show();
mainWindow.focus(); mainWindow.focus();
if (pendingDeepLink) {
applyDeepLink(pendingDeepLink);
}
} }
}); });
} }
pendingDeepLink = extractDeepLinkFromArgv(process.argv);
/**
* Extracts a paarrot:// deep link URL from argv.
* @param {string[]} argv - Process argument vector.
* @returns {string|null} The deep link URL or null.
*/
function extractDeepLinkFromArgv(argv) {
if (!Array.isArray(argv)) {
return null;
}
const match = argv.find((arg) => typeof arg === 'string' && arg.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`));
return match || null;
}
/**
* Converts a paarrot:// deep link into an in-app hash route.
* Supports:
* - Hash routes: paarrot://anything/#/login/server/
* - Path routes (SSO-safe): paarrot://desktop/login/server/?loginToken=...
* Preserves SSO loginToken from the URL query (Synapse inserts it before `#`).
* @param {string} deepLink - The incoming protocol URL.
* @returns {string|null} Hash route value without leading #.
*/
function deepLinkToHashRoute(deepLink) {
if (!deepLink) {
return null;
}
if (!deepLink.toLowerCase().startsWith(`${PROTOCOL_SCHEME}://`)) {
return null;
}
let hashRoute = null;
let loginToken = null;
try {
const parsed = new URL(deepLink);
loginToken = parsed.searchParams.get('loginToken');
if (parsed.hash && parsed.hash.length > 1) {
const hashValue = parsed.hash.slice(1);
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
} else if (parsed.pathname && parsed.pathname !== '/') {
// paarrot://desktop/login/... → /login/...
hashRoute = parsed.pathname;
if (parsed.search && parsed.search.length > 1) {
// Keep non-loginToken query on the route; loginToken merged below
const params = new URLSearchParams(parsed.search);
params.delete('loginToken');
const rest = params.toString();
if (rest) {
hashRoute += `?${rest}`;
}
}
}
} catch {
const hashIndex = deepLink.indexOf('#');
if (hashIndex !== -1) {
const hashValue = deepLink.slice(hashIndex + 1);
if (hashValue) {
hashRoute = hashValue.startsWith('/') ? hashValue : `/${hashValue}`;
}
}
const match = deepLink.match(/[?&]loginToken=([^&#]+)/i);
if (match) {
try {
loginToken = decodeURIComponent(match[1]);
} catch {
loginToken = match[1];
}
}
}
if (loginToken) {
if (!hashRoute) {
hashRoute = '/';
}
const queryIndex = hashRoute.indexOf('?');
const pathPart = queryIndex === -1 ? hashRoute : hashRoute.slice(0, queryIndex);
const params = new URLSearchParams(queryIndex === -1 ? '' : hashRoute.slice(queryIndex + 1));
if (!params.has('loginToken')) {
params.set('loginToken', loginToken);
}
const query = params.toString();
hashRoute = query ? `${pathPart}?${query}` : pathPart;
}
return hashRoute;
}
/**
* Applies a deep link to the current window and brings the app to foreground.
* @param {string} deepLink - The incoming protocol URL.
*/
function applyDeepLink(deepLink) {
if (!mainWindow) {
pendingDeepLink = deepLink;
return;
}
const hashRoute = deepLinkToHashRoute(deepLink);
if (!hashRoute) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
if (isDev) {
mainWindow.loadURL(`${VITE_DEV_SERVER}/#${hashRoute}`).catch((error) => {
console.error('[Protocol] Failed to load deep link route in dev:', error);
});
} else {
mainWindow.loadFile(path.join(__dirname, '../cinny/dist/index.html'), { hash: hashRoute }).catch((error) => {
console.error('[Protocol] Failed to load deep link route in production:', error);
});
}
pendingDeepLink = null;
}
/**
* Registers the app as handler for paarrot:// links.
* @returns {{ before: boolean; registerCall: boolean; after: boolean; error: string | null }}
*/
function registerAppProtocol() {
const before = app.isDefaultProtocolClient(PROTOCOL_SCHEME);
let registered = false;
let error = null;
try {
if (process.defaultApp) {
registered = app.setAsDefaultProtocolClient(PROTOCOL_SCHEME, process.execPath, [path.resolve(process.argv[1])]);
} else {
registered = app.setAsDefaultProtocolClient(PROTOCOL_SCHEME);
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
let after = app.isDefaultProtocolClient(PROTOCOL_SCHEME);
// In Windows dev mode, default ownership may stay false even when registry command points to this app.
if (!after && process.platform === 'win32') {
const currentCommand = getExpectedWindowsProtocolCommand();
const hkcuCommand = queryWindowsRegistryDefaultSync(`HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}\\shell\\open\\command`);
if (hkcuCommand && normalizeWindowsCommand(hkcuCommand) === normalizeWindowsCommand(currentCommand)) {
after = true;
}
}
return {
before,
registerCall: registered,
after,
error,
};
}
/**
* Gets the command that should be registered for paarrot:// on Windows.
* @returns {string}
*/
function getExpectedWindowsProtocolCommand() {
return process.defaultApp && process.argv[1]
? `"${process.execPath}" "${path.resolve(process.argv[1])}" "%1"`
: `"${process.execPath}" "%1"`;
}
/**
* Normalizes a Windows command string for reliable comparisons.
* @param {string} command - Command string to normalize.
* @returns {string}
*/
function normalizeWindowsCommand(command) {
return String(command).replace(/\\/g, '/').replace(/\s+/g, ' ').trim().toLowerCase();
}
/**
* Reads the default Windows registry value synchronously.
* @param {string} keyPath - Registry key path.
* @returns {string|null} Default value content.
*/
function queryWindowsRegistryDefaultSync(keyPath) {
try {
const stdout = execFileSync('reg.exe', ['query', keyPath, '/ve'], { encoding: 'utf8' });
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const valueLine = lines.find((line) => line.startsWith('(Default)'));
if (!valueLine) {
return null;
}
const parsed = valueLine.split(/\s{2,}/).filter(Boolean);
return parsed.length >= 3 ? parsed.slice(2).join(' ') : null;
} catch {
return null;
}
}
function syncProtocolRegistrationState() {
protocolRegistrationState = registerAppProtocol();
console.log(`[Protocol] ${PROTOCOL_SCHEME}:// before=${protocolRegistrationState.before} registerCall=${protocolRegistrationState.registerCall} after=${protocolRegistrationState.after}${protocolRegistrationState.error ? ` error=${protocolRegistrationState.error}` : ''}`);
}
/**
* Refresh Linux xdg-mime association for paarrot:// to the current app desktop file.
* AppImage upgrades often leave a stale desktop id in mimeapps.list.
*/
async function ensureLinuxProtocolHandler() {
const home = app.getPath('home');
const searchDirs = [
path.join(home, '.local', 'share', 'applications'),
'/usr/share/applications',
'/var/lib/flatpak/exports/share/applications',
];
let desktopId = null;
for (const dir of searchDirs) {
try {
if (!fs.existsSync(dir)) continue;
const entries = fs.readdirSync(dir);
// Prefer the newest AppImageLauncher desktop entry when several exist.
const matches = entries
.filter((name) => /paarrot/i.test(name) && name.endsWith('.desktop'))
.map((name) => {
try {
return { name, mtime: fs.statSync(path.join(dir, name)).mtimeMs };
} catch {
return { name, mtime: 0 };
}
})
.sort((a, b) => b.mtime - a.mtime);
if (matches.length > 0) {
desktopId = matches[0].name;
break;
}
} catch {
// keep looking
}
}
if (!desktopId) {
desktopId = 'paarrot.desktop';
}
// Rewrite mimeapps.list so stale AppImage ids cannot win over xdg-mime default.
try {
const mimeappsPath = path.join(home, '.config', 'mimeapps.list');
let content = '';
try {
content = fs.readFileSync(mimeappsPath, 'utf8');
} catch {
content = '';
}
const schemeLine = `x-scheme-handler/${PROTOCOL_SCHEME}=${desktopId}`;
const lines = content ? content.split(/\r?\n/) : [];
let section = null;
const next = [];
let wroteDefault = false;
let wroteAdded = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '[Default Applications]' || trimmed === '[Added Associations]') {
section = trimmed;
next.push(line);
continue;
}
if (trimmed.startsWith('x-scheme-handler/paarrot=')) {
if (section === '[Default Applications]') {
next.push(schemeLine);
wroteDefault = true;
} else if (section === '[Added Associations]') {
next.push(`${schemeLine};`);
wroteAdded = true;
}
continue;
}
next.push(line);
}
if (!next.some((l) => l.trim() === '[Default Applications]')) {
next.push('', '[Default Applications]');
}
if (!wroteDefault) {
const idx = next.findIndex((l) => l.trim() === '[Default Applications]');
next.splice(idx + 1, 0, schemeLine);
}
if (!next.some((l) => l.trim() === '[Added Associations]')) {
next.unshift('[Added Associations]');
}
if (!wroteAdded) {
const idx = next.findIndex((l) => l.trim() === '[Added Associations]');
next.splice(idx + 1, 0, `${schemeLine};`);
}
fs.mkdirSync(path.dirname(mimeappsPath), { recursive: true });
fs.writeFileSync(mimeappsPath, `${next.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`);
console.log(`[Protocol] mimeapps.list → ${desktopId} for x-scheme-handler/${PROTOCOL_SCHEME}`);
} catch (error) {
console.warn('[Protocol] Failed to rewrite mimeapps.list:', error);
}
await new Promise((resolve) => {
execFile(
'xdg-mime',
['default', desktopId, `x-scheme-handler/${PROTOCOL_SCHEME}`],
{ timeout: 5000 },
(error, _stdout, stderr) => {
if (error) {
console.warn(`[Protocol] xdg-mime default failed (${desktopId}):`, error.message, stderr);
} else {
console.log(`[Protocol] xdg-mime default → ${desktopId} for x-scheme-handler/${PROTOCOL_SCHEME}`);
}
resolve();
}
);
});
await new Promise((resolve) => {
execFile('update-desktop-database', [path.join(home, '.local', 'share', 'applications')], { timeout: 5000 }, () => {
resolve();
});
});
}
/**
* Reads a named Windows registry value.
* @param {string} keyPath - Registry key path.
* @param {string} valueName - Registry value name.
* @returns {Promise<string|null>} Registry value content.
*/
async function queryWindowsRegistryValue(keyPath, valueName) {
try {
const { stdout } = await execAsync(`reg query "${keyPath}" /v "${valueName}"`);
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const valueLine = lines.find((line) => line.toLowerCase().startsWith(valueName.toLowerCase()));
if (!valueLine) {
return null;
}
const parsed = valueLine.split(/\s{2,}/).filter(Boolean);
return parsed.length >= 3 ? parsed.slice(2).join(' ') : null;
} catch {
return null;
}
}
/**
* Reads the default Windows registry value.
* @param {string} keyPath - Registry key path.
* @returns {Promise<string|null>} Default value content.
*/
async function queryWindowsRegistryDefault(keyPath) {
try {
const { stdout } = await execAsync(`reg query "${keyPath}" /ve`);
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const valueLine = lines.find((line) => line.startsWith('(Default)'));
if (!valueLine) {
return null;
}
const parsed = valueLine.split(/\s{2,}/).filter(Boolean);
return parsed.length >= 3 ? parsed.slice(2).join(' ') : null;
} catch {
return null;
}
}
/**
* Provides Windows-specific protocol association diagnostics for paarrot://.
* @returns {Promise<{userChoiceProgId: string | null; userChoiceCommand: string | null; hkcuCommand: string | null; hklmCommand: string | null}>}
*/
async function getWindowsProtocolDiagnostics() {
if (process.platform !== 'win32') {
return null;
}
const userChoiceProgId = await queryWindowsRegistryValue(
`HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\${PROTOCOL_SCHEME}\\UserChoice`,
'ProgId'
);
let userChoiceCommand = null;
if (userChoiceProgId) {
userChoiceCommand =
(await queryWindowsRegistryDefault(`HKCU\\Software\\Classes\\${userChoiceProgId}\\shell\\open\\command`)) ||
(await queryWindowsRegistryDefault(`HKLM\\Software\\Classes\\${userChoiceProgId}\\shell\\open\\command`));
}
const hkcuCommand = await queryWindowsRegistryDefault(`HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}\\shell\\open\\command`);
const hklmCommand = await queryWindowsRegistryDefault(`HKLM\\Software\\Classes\\${PROTOCOL_SCHEME}\\shell\\open\\command`);
return {
userChoiceProgId,
userChoiceCommand,
hkcuCommand,
hklmCommand,
};
}
/**
* Ensures Windows has user-level URL protocol class entries for paarrot://
* so the scheme appears in Default Apps link associations.
* @returns {Promise<void>}
*/
async function ensureWindowsProtocolRegistryEntries() {
if (process.platform !== 'win32') {
return;
}
const openCommand = getExpectedWindowsProtocolCommand();
const iconValue = `"${process.execPath}",0`;
const protocolDisplayName = `URL:${PROTOCOL_SCHEME} Protocol`;
await execFileAsync('reg.exe', [
'add',
`HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}`,
'/ve',
'/t',
'REG_SZ',
'/d',
protocolDisplayName,
'/f',
]);
await execFileAsync('reg.exe', [
'add',
`HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}`,
'/v',
'URL Protocol',
'/t',
'REG_SZ',
'/d',
'',
'/f',
]);
await execFileAsync('reg.exe', [
'add',
`HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}\\DefaultIcon`,
'/ve',
'/t',
'REG_SZ',
'/d',
iconValue,
'/f',
]);
await execFileAsync('reg.exe', [
'add',
`HKCU\\Software\\Classes\\${PROTOCOL_SCHEME}\\shell\\open\\command`,
'/ve',
'/t',
'REG_SZ',
'/d',
openCommand,
'/f',
]);
}
/**
* Returns renderer-friendly protocol diagnostics payload.
* @returns {Promise<{ success: boolean; data: { scheme: string; platform: string; isDefault: boolean; before: boolean; registerCall: boolean; error: string | null; windows: null | {userChoiceProgId: string | null; userChoiceCommand: string | null; hkcuCommand: string | null; hklmCommand: string | null} } }>}
*/
async function getProtocolStatusPayload() {
return {
success: true,
data: {
scheme: PROTOCOL_SCHEME,
platform: process.platform,
isDefault: app.isDefaultProtocolClient(PROTOCOL_SCHEME),
before: protocolRegistrationState.before,
registerCall: protocolRegistrationState.registerCall,
error: protocolRegistrationState.error,
windows: await getWindowsProtocolDiagnostics(),
},
};
}
// Helper to get correct icon path in dev vs packaged app // Helper to get correct icon path in dev vs packaged app
function getIconPath(iconName) { function getIconPath(iconName) {
if (isDev) { if (isDev) {
@@ -400,15 +991,10 @@ function createWindow() {
mainWindow = null; mainWindow = null;
}); });
// Open external links in default browser // Open external links in default browser (keep same-origin / Vite in Electron)
mainWindow.webContents.setWindowOpenHandler(({ url, features }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http://') || url.startsWith('https://')) {
shell.openExternal(url);
return { action: 'deny' };
}
// Allow blank windows for screen share pop-outs with custom features
if (url === '' || url === 'about:blank') { if (url === '' || url === 'about:blank') {
return { return {
action: 'allow', action: 'allow',
overrideBrowserWindowOptions: { overrideBrowserWindowOptions: {
frame: false, frame: false,
@@ -418,30 +1004,39 @@ function createWindow() {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
backgroundThrottling: false, backgroundThrottling: false,
} },
} },
}; };
} }
if (isAppOwnedUrl(url, mainWindow.webContents.getURL())) {
return { action: 'allow' };
}
if (url.startsWith('http://') || url.startsWith('https://')) {
openExternalUrl(url);
return { action: 'deny' };
}
return { action: 'allow' }; return { action: 'allow' };
}); });
// Navigation handler - open external links in browser // Navigation handler - open external links in browser.
// Same-origin / local Vite navigations (HMR full reload) must stay in-app.
mainWindow.webContents.on('will-navigate', (event, url) => { mainWindow.webContents.on('will-navigate', (event, url) => {
const allowedOrigins = [ if (url.startsWith('blob:') || url.startsWith('data:')) {
'http://localhost:8080', return;
'http://localhost:44548',
'http://127.0.0.1:8080',
'http://127.0.0.1:44548'
];
const isAllowed = allowedOrigins.some(origin => url.startsWith(origin)) ||
url.startsWith('blob:') ||
url.startsWith('data:');
if (!isAllowed && (url.startsWith('http://') || url.startsWith('https://'))) {
event.preventDefault();
shell.openExternal(url);
} }
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return;
}
if (isAppOwnedUrl(url, mainWindow.webContents.getURL())) {
return;
}
event.preventDefault();
openExternalUrl(url);
}); });
return mainWindow; return mainWindow;
@@ -490,8 +1085,26 @@ function createTray() {
// App lifecycle // App lifecycle
app.whenReady().then(() => { app.whenReady().then(() => {
if (process.platform === 'win32') {
ensureWindowsProtocolRegistryEntries().catch((error) => {
console.warn('[Protocol] Startup registry ensure failed:', error);
});
}
syncProtocolRegistrationState();
if (process.platform === 'linux') {
ensureLinuxProtocolHandler().catch((error) => {
console.warn('[Protocol] Startup Linux protocol ensure failed:', error);
});
}
createWindow(); createWindow();
createTray(); createTray();
if (pendingDeepLink) {
applyDeepLink(pendingDeepLink);
}
// Start API server // Start API server
apiServer = new PaarrotAPIServer(mainWindow); apiServer = new PaarrotAPIServer(mainWindow);
@@ -499,24 +1112,38 @@ app.whenReady().then(() => {
console.error('Failed to start API server:', err); console.error('Failed to start API server:', err);
}); });
// Check for updates on startup (not in development mode) // Check for updates on startup
if (!isDev) { setTimeout(() => {
// Check for updates on start (after 3 seconds) if (isDev) {
setTimeout(() => { // Exercise the title-bar updater UI with a mock update.
autoUpdater.checkForUpdates().catch(err => { sendUpdaterEvent('update-available', {
console.error('Failed to check for updates:', err); version: '99.0.0-dev',
releaseNotes: 'Mock update for development — download to preview the title-bar progress UI.',
releaseDate: new Date().toISOString(),
mock: true,
}); });
}, 3000); return;
}
autoUpdater.checkForUpdates().catch((err) => {
console.error('Failed to check for updates:', err);
});
}, 2500);
if (!isDev) {
// Check for updates every 6 hours // Check for updates every 6 hours
setInterval(() => { setInterval(() => {
autoUpdater.checkForUpdates().catch(err => { autoUpdater.checkForUpdates().catch((err) => {
console.error('Failed to check for updates:', err); console.error('Failed to check for updates:', err);
}); });
}, 6 * 60 * 60 * 1000); }, 6 * 60 * 60 * 1000);
} }
}); });
app.on('open-url', (event, url) => {
event.preventDefault();
applyDeepLink(url);
});
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
// On macOS, apps stay active until Cmd+Q // On macOS, apps stay active until Cmd+Q
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
@@ -591,6 +1218,44 @@ ipcMain.handle('window:is-maximized', () => {
return mainWindow ? mainWindow.isMaximized() : false; return mainWindow ? mainWindow.isMaximized() : false;
}); });
/**
* Returns protocol registration health for the renderer settings page.
* @returns {Promise<{ success: boolean; data: { scheme: string; platform: string; isDefault: boolean; before: boolean; registerCall: boolean; error: string | null; windows: null | {userChoiceProgId: string | null; userChoiceCommand: string | null; hkcuCommand: string | null; hklmCommand: string | null} } }>}
*/
ipcMain.handle('protocol:get-status', () => {
return getProtocolStatusPayload();
});
/**
* Re-runs protocol registry ensure and protocol registration.
* @returns {Promise<{ success: boolean; data: { scheme: string; platform: string; isDefault: boolean; before: boolean; registerCall: boolean; error: string | null; windows: null | {userChoiceProgId: string | null; userChoiceCommand: string | null; hkcuCommand: string | null; hklmCommand: string | null} } }>}
*/
ipcMain.handle('protocol:repair', async () => {
if (process.platform === 'win32') {
try {
await ensureWindowsProtocolRegistryEntries();
} catch (error) {
console.warn('[Protocol] Failed to write Windows URL protocol registry entries:', error);
}
}
syncProtocolRegistrationState();
if (process.platform === 'linux') {
try {
await ensureLinuxProtocolHandler();
} catch (error) {
console.warn('[Protocol] Failed to refresh Linux protocol handler:', error);
}
}
const status = await getProtocolStatusPayload();
return {
success: true,
data: status.data,
};
});
ipcMain.handle('window:start-drag', () => { ipcMain.handle('window:start-drag', () => {
if (mainWindow) mainWindow.webContents.startDrag({ file: '', icon: nativeImage.createEmpty() }); if (mainWindow) mainWindow.webContents.startDrag({ file: '', icon: nativeImage.createEmpty() });
}); });
@@ -603,7 +1268,7 @@ ipcMain.handle('plugin:app|version', () => {
// Open external URL // Open external URL
ipcMain.handle('open-external-url', async (event, url) => { ipcMain.handle('open-external-url', async (event, url) => {
try { try {
await shell.openExternal(url); await openExternalUrl(url);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -726,10 +1391,61 @@ ipcMain.handle('get-background-sync-state', async () => {
return { success: true, data: 'NotApplicable' }; return { success: true, data: 'NotApplicable' };
}); });
// Auto-updater IPC handlers // Auto-updater IPC handlers (mock in development so title-bar UI can be exercised)
let mockDownloadTimer = null;
let mockDownloadPercent = 0;
function sendUpdaterEvent(channel, payload) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload);
}
}
function startMockDownload() {
if (mockDownloadTimer) {
clearInterval(mockDownloadTimer);
}
mockDownloadPercent = 0;
sendUpdaterEvent('update-download-progress', {
percent: 0,
transferred: 0,
total: 100,
mock: true,
});
mockDownloadTimer = setInterval(() => {
mockDownloadPercent = Math.min(100, mockDownloadPercent + 4 + Math.random() * 6);
const percent = Math.round(mockDownloadPercent);
sendUpdaterEvent('update-download-progress', {
percent,
transferred: percent,
total: 100,
mock: true,
});
if (percent >= 100) {
clearInterval(mockDownloadTimer);
mockDownloadTimer = null;
sendUpdaterEvent('update-downloaded', {
version: '99.0.0-dev',
releaseNotes: 'Mock update for development.',
mock: true,
});
}
}, 180);
}
ipcMain.handle('check-for-updates', async () => { ipcMain.handle('check-for-updates', async () => {
if (isDev) { if (isDev) {
return { success: false, error: 'Updates are not available in development mode' }; sendUpdaterEvent('update-checking', { mock: true });
await new Promise((resolve) => setTimeout(resolve, 600));
sendUpdaterEvent('update-available', {
version: '99.0.0-dev',
releaseNotes: 'Mock update for development — download to preview the title-bar progress UI.',
releaseDate: new Date().toISOString(),
mock: true,
});
return { success: true, data: { updateInfo: { version: '99.0.0-dev' }, mock: true } };
} }
try { try {
const result = await autoUpdater.checkForUpdates(); const result = await autoUpdater.checkForUpdates();
@@ -741,7 +1457,8 @@ ipcMain.handle('check-for-updates', async () => {
ipcMain.handle('download-update', async () => { ipcMain.handle('download-update', async () => {
if (isDev) { if (isDev) {
return { success: false, error: 'Updates are not available in development mode' }; startMockDownload();
return { success: true, data: { mock: true } };
} }
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
@@ -753,7 +1470,12 @@ ipcMain.handle('download-update', async () => {
ipcMain.handle('install-update', () => { ipcMain.handle('install-update', () => {
if (isDev) { if (isDev) {
return { success: false, error: 'Updates are not available in development mode' }; sendUpdaterEvent('update-not-available', {
version: app.getVersion(),
mock: true,
message: 'Mock install — restart skipped in development.',
});
return { success: true, data: { mock: true } };
} }
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
return { success: true }; return { success: true };
@@ -761,7 +1483,8 @@ ipcMain.handle('install-update', () => {
ipcMain.handle('download-and-install-update', async () => { ipcMain.handle('download-and-install-update', async () => {
if (isDev) { if (isDev) {
return { success: false, error: 'Updates are not available in development mode' }; startMockDownload();
return { success: true, data: { mock: true } };
} }
try { try {
await autoUpdater.downloadUpdate(); await autoUpdater.downloadUpdate();
@@ -772,6 +1495,8 @@ ipcMain.handle('download-and-install-update', async () => {
} }
}); });
ipcMain.handle('updater-is-mock', () => ({ success: true, data: { mock: isDev } }));
// Screen capture / desktopCapturer handler // Screen capture / desktopCapturer handler
ipcMain.handle('get-desktop-sources', async (event, opts) => { ipcMain.handle('get-desktop-sources', async (event, opts) => {
try { try {

View File

@@ -18,7 +18,8 @@ const commandMap = {
'check_for_updates': 'check-for-updates', 'check_for_updates': 'check-for-updates',
'download_update': 'download-update', 'download_update': 'download-update',
'install_update': 'install-update', 'install_update': 'install-update',
'get_desktop_sources': 'get-desktop-sources' 'get_desktop_sources': 'get-desktop-sources',
'get_protocol_status': 'protocol:get-status'
}; };
// Shared invoke function for legacy API compatibility // Shared invoke function for legacy API compatibility
@@ -129,6 +130,7 @@ contextBridge.exposeInMainWorld('electron', {
updater: { updater: {
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadAndInstallUpdate: () => ipcRenderer.invoke('download-and-install-update'), downloadAndInstallUpdate: () => ipcRenderer.invoke('download-and-install-update'),
isMock: () => ipcRenderer.invoke('updater-is-mock'),
onUpdateAvailable: (callback) => { onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (_, info) => callback(info)); ipcRenderer.on('update-available', (_, info) => callback(info));
}, },
@@ -138,6 +140,9 @@ contextBridge.exposeInMainWorld('electron', {
onUpdateDownloaded: (callback) => { onUpdateDownloaded: (callback) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info)); ipcRenderer.on('update-downloaded', (_, info) => callback(info));
}, },
onUpdateNotAvailable: (callback) => {
ipcRenderer.on('update-not-available', (_, info) => callback(info));
},
downloadUpdate: () => ipcRenderer.invoke('download-update'), downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'), installUpdate: () => ipcRenderer.invoke('install-update'),
}, },
@@ -176,6 +181,12 @@ contextBridge.exposeInMainWorld('electron', {
list: () => ipcRenderer.invoke('plugin:list'), list: () => ipcRenderer.invoke('plugin:list'),
uninstall: (pluginId) => ipcRenderer.invoke('plugin:uninstall', pluginId), uninstall: (pluginId) => ipcRenderer.invoke('plugin:uninstall', pluginId),
readPluginCode: (pluginId) => ipcRenderer.invoke('plugin:read-code', pluginId) readPluginCode: (pluginId) => ipcRenderer.invoke('plugin:read-code', pluginId)
},
// Desktop protocol diagnostics
protocol: {
getStatus: () => ipcRenderer.invoke('protocol:get-status'),
repair: () => ipcRenderer.invoke('protocol:repair')
} }
}); });

3521
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.121", "version": "4.11.168",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"homepage": "https://github.com/Paarrot/Paarrot-Desktop", "homepage": "https://github.com/Paarrot/Paarrot-Desktop",
"repository": { "repository": {
@@ -13,7 +13,7 @@
}, },
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"", "dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
"dev:vite": "cd cinny && npm start", "dev:vite": "cross-env BROWSER=none sh -c 'cd cinny && npm start'",
"dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .",
"build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always", "build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish always",
"build:local": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder", "build:local": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder",
@@ -28,24 +28,28 @@
}, },
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.18",
"body-parser": "2.2.2", "body-parser": "2.3.0",
"cors": "2.8.6", "cors": "2.8.6",
"electron-store": "^8.2.0", "electron-store": "^11.0.2",
"electron-updater": "^6.3.9", "electron-updater": "^6.8.9",
"express": "5.2.1", "express": "5.2.1",
"open": "11.0.0" "open": "11.0.0"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "6.0.0", "@actions/github": "9.1.1",
"@electron/rebuild": "4.0.3", "@electron/rebuild": "4.2.0",
"concurrently": "9.2.1", "concurrently": "10.0.3",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"electron": "40.6.0", "electron": "43.1.0",
"electron-builder": "26.8.1", "electron-builder": "26.15.3",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"png2icons": "2.0.1", "png2icons": "2.0.1",
"sharp": "0.34.5", "sharp": "0.35.3",
"wait-on": "9.0.4" "wait-on": "9.0.10"
},
"allowScripts": {
"electron-winstaller@5.4.0": true,
"electron@43.1.0": true
} }
} }

104
scripts/migrate-icons.mjs Normal file
View File

@@ -0,0 +1,104 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srcRoot = path.resolve(__dirname, '../cinny/src');
const iconsModule = path.resolve(srcRoot, 'app/components/icons');
const ICON_SYMBOLS = new Set(['Icon', 'Icons', 'IconName', 'IconSrc']);
function walk(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath, files);
} else if (/\.(tsx?)$/.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
function getIconsImportPath(filePath) {
const rel = path.relative(path.dirname(filePath), iconsModule).replace(/\\/g, '/');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function splitFoldsImport(importClause) {
const match = importClause.match(/^\{([^}]+)\}(?:\s+as\s+\w+)?$/);
if (!match) return null;
const specifiers = match[1]
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [local, imported] = part.split(/\s+as\s+/).map((s) => s.trim());
return { local: local ?? imported, imported: imported ?? local };
});
const iconSpecs = specifiers.filter((s) => ICON_SYMBOLS.has(s.imported));
const foldsSpecs = specifiers.filter((s) => !ICON_SYMBOLS.has(s.imported));
if (iconSpecs.length === 0) return null;
return { iconSpecs, foldsSpecs };
}
function migrateFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
const importRegex = /import\s+(\{[^}]+\})\s+from\s+['"]folds['"];?/g;
let changed = false;
const iconImports = new Map();
content = content.replace(importRegex, (fullMatch, importClause) => {
const split = splitFoldsImport(importClause);
if (!split) return fullMatch;
changed = true;
for (const spec of split.iconSpecs) {
iconImports.set(spec.local, spec.imported);
}
if (split.foldsSpecs.length === 0) {
return '';
}
const foldsImport = split.foldsSpecs.map((s) => (s.local === s.imported ? s.local : `${s.imported} as ${s.local}`)).join(', ');
return `import { ${foldsImport} } from 'folds';`;
});
if (!changed) return false;
const iconsPath = getIconsImportPath(filePath);
const iconExportList = [...iconImports.entries()]
.map(([local, imported]) => (local === imported ? imported : `${imported} as ${local}`))
.join(', ');
const iconsImportLine = `import { ${iconExportList} } from '${iconsPath}';`;
const foldsImportMatch = content.match(/^import\s+\{[^}]+\}\s+from\s+['"]folds['"];?\s*$/m);
if (foldsImportMatch) {
const insertAt = content.indexOf(foldsImportMatch[0]) + foldsImportMatch[0].length;
content = `${content.slice(0, insertAt)}\n${iconsImportLine}${content.slice(insertAt)}`;
} else {
content = `${iconsImportLine}\n${content}`;
}
content = content.replace(/\n{3,}/g, '\n\n');
fs.writeFileSync(filePath, content);
return true;
}
const files = walk(srcRoot);
let migrated = 0;
for (const file of files) {
if (file.includes(`${path.sep}components${path.sep}icons${path.sep}`)) continue;
if (migrateFile(file)) {
migrated += 1;
}
}
console.log(`Migrated ${migrated} files.`);