49 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 9b7511f8bf chore(ops): version the release scripts
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
They existed only in /opt/ops on the VPS — no history, no backup. Rebuilding
that machine would have taken the whole publishing process with it, including
the Android signing flow written today.

/opt/ops now symlinks here so there is one copy rather than two that drift.
No secrets move: the scripts read tokens and the signing key from paths under
/root that stay on the VPS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:21:00 -03:00
devsyncwrldandClaude Opus 5 cc2b8d3d33 feat(mobile): keep voice alive with the screen off
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
A call dropped as soon as the screen locked. The cause is not configuration:
Android freezes background processes, and a frozen WebView stops running the
JavaScript that holds the LiveKit connection up. The only thing the system
accepts as "this app must keep running" is a foreground service, which is why
the persistent notification comes with it.

Two mechanisms, both needed: the service stops the process from being frozen,
and a partial wake lock stops the CPU from sleeping. Either one alone still
drops the call.

The notification carries a hangup action. The service cannot end the call
itself — only the web app knows how to leave a LiveKit room — so it notifies
the plugin's listeners and the web side disconnects through the same path the
in-app button uses.

The web side starts the service on mount when a call is already in progress,
not just on transition: the WebView can reload mid-call, and waiting for the
next connect would leave that call unprotected with no visible symptom until
someone locked their screen.

POST_NOTIFICATIONS is requested at launch on Android 13+. Denying it does not
drop calls — the service still runs — it only hides the notification.

Tests cover connect/disconnect, the already-in-a-call mount, the hangup action,
and that the component is inert in the browser and desktop builds. Verified by
mutation that dropping the start call fails the first one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:44:35 -03:00
devsyncwrldandClaude Opus 5 48e8bc537d ci(mobile): point setup-node at the mobile package's npm lockfile
Without cache-dependency-path the action falls back to the root package.json's
`packageManager: pnpm` field and fails looking for a pnpm this workflow never
installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:11:46 -03:00
devsyncwrldandClaude Opus 5 f808f2f535 feat(mobile): Android app shell with Capacitor
The web client already has a full mobile UI (MobileShell and 17 sibling
screens) and ships as a PWA, so what was missing to get an APK was a native
container, not an interface.

The WebView loads the instance over the network instead of bundling a copy of
the web build. Bundling would mean republishing an APK on every site deploy —
there is no electron-updater equivalent here — and it would move the page to
https://localhost, turning every same-origin /api and /ws call into a
cross-origin one. The cost is no offline mode, which for a realtime chat app
costs nothing: public/index.html covers the unreachable-server case.

packages/mobile sits outside the pnpm workspace on purpose. Capacitor's CLI and
Gradle both assume a flat node_modules, and keeping it out leaves pnpm-lock.yaml
and the existing web/desktop CI untouched by it.

Icons are generated from the existing brand art. The launch screen is a
layer-list over the brand background rather than Capacitor's per-density
splash.png set, which is light and flashed white before the WebView painted.

CI builds an *unsigned* APK. The signing key stays on the VPS and is applied
there — an APK signature is what proves an update came from us, so the key has
no reason to exist on a second machine.

Background voice (foreground service) is not in this commit: a plain WebView is
throttled when the screen goes off and the call drops. That is the next phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:09:15 -03:00
devsyncwrldandClaude Opus 5 aee78c930d fix(desktop): announce staged updates reliably and install them silently
Two separate reasons "Restart to update" only ever showed up after pressing
"check for updates" by hand, and one reason the restart was unpleasant.

1. `update-downloaded` is a one-shot push to whichever page happens to be
   loaded. It is dropped when the window is in the tray (mainWindow is null),
   when the 10s post-launch check beats the web app's listener registration,
   and on every reload. Once an update is staged the poll latches off, so it
   was never re-announced — a manual check was the only way back. The main
   process already tracks this in recoveryStore, so expose it over IPC and have
   the banner ask on mount rather than rely on catching the event live.

2. UpdateBanner was only mounted in the mobile layout. On the desktop app the
   visible one was UpdateToast, which duplicated the same "update ready" card
   in untranslated English. Mount the banner on desktop too and reduce the
   toast to the failure case it does not share.

3. quitAndInstall() defaults to a non-silent install, so on Windows restarting
   meant clicking through the NSIS wizard. Pass silent + force-run-after, and
   switch the installer to one-click per-user, which is what lets the silent
   install proceed without an elevation prompt.

The manual-download link in the failure toast pointed at the upstream GitHub
repository, which is private for this fork and 404'd for everyone. Point it at
the fork's Gitea releases, where the updater feed already lives.

Regression test covers the case the live event never announced; verified by
mutation that removing the status query fails it and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:26:30 -03:00
devsyncwrldandClaude Opus 5 5e7adfb38d chore: version 2.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:09:30 -03:00
devsyncwrldandClaude Opus 5 033ba63762 feat(profile): banner repositioning, and the editor rebuilt on the reference's shape
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Three things, from the screenshots.

Banner repositioning, which was a real hole. A banner was always cropped dead
centre, and a GIF could not go through the still-image cropper at all — so
there was no way to frame one. The crop is now two percentages and a zoom
stored in the profile (`banner: {x, y, zoom}`), and the banner renders as an
<img> with object-position plus a scale rather than a CSS background: those two
compose into pan-and-zoom, where `background-size: cover` cannot be zoomed
without knowing the file's dimensions. The editor drags the live element, so a
GIF keeps animating while you frame it, and nothing is ever re-encoded.

The editor now has the reference's shape: a rail of cards on the left, each
showing what it currently holds, and the live profile beside it. A card opens a
dialog with the options on one side and a preview on the other. That is the
part both earlier attempts got wrong — the first put a 360px preview inside a
640px column, the second made you leave the preview to open a section. Here
they are on screen together at full size.

The dialog edits a copy: Cancel discards it, Apply commits to the draft, and
the draft is only persisted by Save. So backing out of a dialog cannot cost
unrelated work, and neither can the per-section reset in its header.

"Surprise me" randomises the look and nothing else. Fonts, effects, decoration,
theme and nameplate change; pronouns, tag, links, board and the banner crop are
the user's own words and framing, and rolling dice over those would be
vandalism. A test asserts exactly that over 40 rolls, and that every roll lands
on ids the normaliser accepts.

Five more nameplates in the y2k direction — holo, chrome, vapor, matrix, ember
— since the existing ten were all plain gradients.

Verified: shared build, web build, server typecheck, 523 web tests (5 new:
banner clamping and round-trip, randomiser bounds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:02:29 -03:00
devsyncwrldandClaude Opus 5 e6eba7a2b7 fix(profile): align the decorations, and make the effects actually look like something
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Three complaints, three causes.

Decorations sat off-centre. <Avatar>'s box is `size + 2 × ringWidth`, but the
overlay was positioned with a percentage inset of that box — so it drifted by
exactly the ring whenever one was present. That is why the crown floated above
the head on the profile card while looking fine on the editor tiles, which
have no ring. DecoratedAvatar now takes `size` and `ringWidth` and computes
the inset in pixels, so the picture's edge always lands at a known radius in
the SVG viewBox and every decoration is drawn against it.

The ugly ones were ugly. Flames were a blob with a black circle punched out of
it; they are now fourteen tongues of alternating height on a gradient. The
crown's band sits ON the picture's top edge instead of hovering above it.
Frost, stars and hearts were placed by eye and are now on a computed ring.
Nine more: holo disc (a conic gradient — the honest way to draw one, and CSS
has it where SVG does not), chain, cyber brackets, lightning, spikes,
butterflies, sakura, bubbles, flower. Twenty in total.

Name effects were thin. Gradient was a two-stop ramp; it now has a lit middle.
Neon was one shadow, now five layered with a buzz. Glitch was a keyframed
text-shadow, now a real RGB split duplicated through ::before/::after from
data-text. Added chrome and gold (banded metal with a specular line — the y2k
look the reference is after), holo, rainbow, ice, 3D extrude, and outline.
Sixteen effects, thirteen fonts with Audiowide, Bungee and Monoton added.

The editor tiles now sample the user's own name rather than "Gg" or the effect
label: an effect reads differently on a short word than on the name it will
carry. Two per row instead of three, and section rows gained an icon.

One test failed on the catalog growth and was right to: it used 'rainbow' as
an example of an unknown id, and rainbow is now a real effect. It uses an
absurd id instead, and a new test walks every catalog id through the
normaliser so a catalog and the normaliser can never drift apart silently.

Verified: shared build, web build, server typecheck, 518 web tests (1 new).
Not verified: pixels — no browser here; the geometry is now computed rather
than guessed, but a screenshot decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:46:20 -03:00
devsyncwrldandClaude Fable 5.1 723522b90d fix(profile): give the editor room — three panels instead of a cramped column
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner's verdict on the first editor was "imprensado", and it was: the
settings shell caps its content at 640px, and the editor put a 360px preview
next to six-column tile grids inside that. Everything was legible and nothing
was usable.

Now it follows the shape of the reference (Discord's profile editor):

  - left, 320px: the list of sections, each showing its current value as a
    mini preview (the nameplate strip, the decorated avatar, the styled name,
    the theme swatch, the effect thumbnail). Picking one replaces the list
    with that section's editor, with a back arrow — a drill-down instead of
    sub-modals, one level deep.
  - centre: the live card, plus two in-context samples — how the name and tag
    read on a chat line, and the row in the member list with its nameplate.
  - right: the Board as others see it, with an Edit shortcut.

Tiles went from 5–6 per row to 2–3, with real size (Gg samples at 26px,
decorations on a 56px avatar, effect thumbnails 64px tall). The settings
shell drops its width cap and lets the panel fill the height for this tab
only; every other panel keeps the single reading column. Stacks to one
column below the lg breakpoint.

Verified: web build, 517 web tests. Not verified: pixels — there is no
browser here, so the proportions are from the reference and the numbers, and
a screenshot decides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 09:27:38 -03:00
devsyncwrldandClaude Fable 5.1 e7094014b7 fix(shared): profile.ts must not reference the URL global type
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
packages/shared compiles without the DOM or Node type libraries, so `URL` has
no type there even though it exists at runtime everywhere this code runs. The
web and server builds resolved the same file under their own libs and passed;
only the shared package's own `tsc` — which CI runs first — failed, on both
runners. Reach the constructor through globalThis with a minimal structural
type instead of pulling a lib into a types-only package. Runtime unchanged,
so the deployed site (82798d5c) needs no redeploy for this.

Lesson recorded for the local checklist: run `pnpm --filter @backspace/shared
build` too — the root `typecheck` script already does; the web build alone
does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:44:35 -03:00
devsyncwrldandClaude Fable 5.1 82798d5c5a chore: version 2.0.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The rebrand to Resenhacord moves the install and the profile directory;
a major bump marks that boundary for anyone reading the version string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:38:30 -03:00
devsyncwrldandClaude Fable 5.1 ef9c73501c feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.

What a user can now set, and where it shows:

  - Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
    pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
  - Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
    hearts, frost, crown). Inline SVG — no image assets, scales to any size.
  - Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
    confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
    positions so re-renders do not change the weather.
  - Nameplate behind the member-list row: 10 presets + custom two-colour.
  - Theme: two colours washed behind the card, and the banner fallback.
  - Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
    also on the member row.
  - Links, up to 5, with the brand icon picked from the host.
  - Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
    Games come from Steam search through a small server proxy (cached,
    10 min) or are typed by hand; covers only load from Steam's CDN.
  - Badges, derived rather than stored: instance admin, server owner,
    founder (joined in the space's first month), veteran (a year), OG (six
    months), stylist. Nothing to grant, revoke or migrate.

Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.

The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.

Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.

Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:36:23 -03:00
devsyncwrldandClaude Fable 5.1 872066e5be feat: rebrand to Resenhacord with a new logo, and post a notice when a message is pinned
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Rebrand. The product name is now Resenhacord everywhere a person reads it:
window title, tray, notifications, About panel, PWA manifest, page title,
recovery and instance-picker screens, and the four i18n strings that named
the app. Code identifiers (BackspaceElectronAPI, @backspace/* workspace
names, the federation protocol) are unchanged — they are not a brand, and
renaming them buys nothing but churn. AGPL notices and upstream attribution
stay as they were.

Two rename details that would each have hurt real users:

  - electron-builder's productName changed but appId did not. NSIS keys the
    installation on appId, so the updater replaces the Backspace install in
    place instead of leaving a second app beside it.
  - userData is keyed on app name, so the rename would have moved every
    install to an empty profile — logged out, instance URL gone. main.ts now
    walks a migration chain ("@backspace/desktop" → "Backspace" →
    "Resenhacord"), each step a no-op once its source is gone, and one whose
    target already holds data leaves it alone. Autostart lookups accept the
    old registry entry name too.

Logo. Two speech bubbles in conversation, violet behind, rose in front with
three dots — the front one is talking. Sources are hand-written SVG in
assets/brand (square viewBox; the gap between bubbles is a mask, not paint,
so the alpha-only macOS template keeps the silhouette). PNG sources are
rendered from the icon SVG and the whole set — .icns, .ico, Linux PNGs, tray
variants, favicons, PWA, maskable — comes from the existing gen-icons
pipeline unchanged.

Pin notice. Pinning a message now posts "X pinned a message to this channel.
See all pinned messages." into the channel, the way Discord does, with two
links: the message itself (jumps to it) and the pins panel. Space messages
had no `type` column — only DM messages carried system events — so this adds
one (migration 0018, default 'user', verified against a copy of the
production database: 88 rows, all 'user'). The route inserts a type='system'
row alongside the pin and broadcasts it as message_created, so the client
renders it with the SystemMessage renderer it already has. Unpinning posts
nothing; it is housekeeping, not news. System notices are excluded from the
notification toast and the message sound — they are not from anyone.

The pins panel and jump target are local state in MainContent; a notice deep
in the timeline reaches them through two small uiStore requests (a counter
and an id), neither persisted.

Verified: web build, 506 web tests (2 new), server typecheck, 804 server
tests, desktop typecheck.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:15:14 -03:00
devsyncwrldandClaude Opus 5 a41a944742 feat(chat): @everyone mention
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Rides the existing `<@id>` token: `everyone` already matches the parser's
`[a-zA-Z0-9_-]+`, so the markdown pre-processor, the inline renderer and the
badge pick it up with no second syntax. User ids are snowflakes, so the
literal `everyone` cannot collide with one.

Gated on a new MENTION_EVERYONE permission rather than being open to all —
otherwise a role can never take it away. Granted to @everyone by default: on
a small instance the ping is a convenience, not a weapon, and an owner who
disagrees can deny it and grant it to a role instead.

Two things that would each have made the gate a no-op:

  - DEFAULT_EVERYONE_PERMISSIONS only applies at space creation, so spaces
    that predate the bit would have had every @everyone mention silently
    stripped. A boot-time backfill ORs the bit into @everyone roles only
    (`id = space_id`), leaving other roles alone so a later denial is not
    overruled on the next restart. Verified against a copy of the production
    database: three roles moved by exactly 2^15, non-@everyone roles
    untouched, second pass a no-op.
  - Enforcement covers edit as well as create; without that, posting a plain
    message and editing it is the way around the permission.

A message that lacks the permission is not rejected — losing what someone
wrote over one token is worse than posting it. The token is demoted to the
literal `@everyone` text they typed: the message reads the same and rings
nobody.

Code spans and fences never ping. People paste payloads and snippets into
chat, and a paste should not ring everyone's client. The same rule decides
the row highlight and the notification sound, so the three cannot disagree.

Verified: web build, server typecheck, 504 web tests, 804 server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:10:56 -03:00
devsyncwrldandClaude Opus 5 1ec5d88fa2 chore: version 1.5.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:58:05 -03:00
devsyncwrldandClaude Opus 5 699855849d feat(i18n): translate the five systems users actually see
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Ordered by what the group hits, not by string count.

  - Chat system messages ("X added Y to the group") and their sidebar
    preview twin. These are content, not chrome, so they sat in English in
    the middle of conversations.
  - Login, register and the invite landing page — the first screen anyone
    invited to the instance sees.
  - Voice — controls, connection info, the mobile join sheet, settings tab.
  - Mobile screens.
  - Settings navigation across space, user and instance, plus Discovery and
    join requests.

Three call sites build strings outside a component and so read the locale
directly rather than through the hook: the sidebar preview formatter, the
connection-info track labels, and MobileShell's screen map — which holds
plain functions that are *called*, not rendered, where a hook would run
under whichever component invoked the map. Each says so at the definition;
all three re-render often enough that a language switch shows through
promptly.

Adds a test for a mistake made three times in this series: a file with an
exported component plus a private sub-component, where only the exported one
got the hook. tsc catches it, but only after a full build and without naming
the component. The test names it, and also flags the inverse — a hook placed
below an early return, which tsc accepts and React breaks at runtime (this
project shipped that once, as error #310). Verified by mutation: removing a
hook makes it fail and print the component.

Dictionary is at 856 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 492 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:13:54 -03:00
devsyncwrldandClaude Opus 5 94f5da4013 feat(i18n): translate five more systems, and unify the permission labels
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Space settings > Overview — identity, icon, banner, transfer, delete
  - Space settings > Members and Bans
  - Space settings > Roles, including the whole permission table
  - Category settings — rename, privacy, overrides, delete
  - Instance settings > General — name, discovery, GIF search

The permission labels were duplicated: the roles panel carried one English
list and the channel/category override editors carried another. Both now
read a single `perm.*` set, so renaming a permission lands in all three at
once instead of one and a half. This removes the `channel.perm.*` keys added
two commits ago rather than adding a third copy beside them.

Same pattern as before for anything chosen outside the render: the roles
panel's permission groups and the category's override definitions hold keys
and resolve them where they render. The category delete dialog emphasises
the category name, so its sentence is split around the placeholder rather
than interpolated.

Dictionary is at 723 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:49:25 -03:00
devsyncwrldandClaude Opus 5 4fd6ac890c feat(i18n): translate five more systems
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Settings > Voice: video — preview, permission states, camera picker.
    This closes the Voice & Video tab; input, output and video are all done.
  - Create channel / category / space
  - Group DMs — new DM, add friends, group settings, leave
  - Join a space — discovery, invite code, and both federated auth phases
  - Delete account, transfer ownership, and the top-level error screen

Three patterns worth noting. Strings that are chosen in a helper or held in
state (the camera's hard-blocked copy, its preview errors, the space
visibility options, the invite failure reasons) now travel as translation
keys and are resolved where they render, so switching language re-labels
what is already on screen. Sentences that wrap an emphasised literal — the
host name when joining, the space and member names when transferring — are
split around their placeholder instead of interpolated, leaving word order
to the translation. And `CreateChannel` mapped over `(t) =>` for the channel
type, which shadowed the translator; the loop variable is now `channelType`.

The error boundary in main.tsx is a class component, so it reads the locale
once per render via `translate` rather than the hook — correct here anyway,
since an error screen has no reason to re-render on a language switch.

Two GroupDmSettings tests located the name input by its aria-label, which
this change translates. They now use the `data-group-dm-name-input`
attribute the input already carries, matching how the rest of that file
locates elements — and no longer break when copy changes.

Dictionary is at 595 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:38:54 -03:00
devsyncwrldandClaude Opus 5 115c81cba3 feat(i18n): translate five more systems, and check for updates more often
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Update cadence: the desktop app checked once ten seconds after launch and
then every four hours. A release published while the app sits open went
unnoticed until the next poll — which is exactly what happened with 1.4.0.
It now polls every 30 minutes and also checks when the window regains focus,
throttled to one check per five minutes so alt-tabbing does not hammer the
feed. `lastCheckAt` is stamped from the `checking-for-update` event rather
than by the callers, so a manual check from the settings panel counts too.
The focus listener is on `app`, not on mainWindow, because the window is
destroyed and recreated when closed to tray.

Translations, five whole systems:
  - Settings > Desktop (autostart, updates, instance)
  - Settings > Voice: output device, which completes that tab
  - Settings > Keybinds, including the action names and conflict prompt
  - Settings > Account, the largest — profile, password, detached-instance
    notice, danger zone
  - Explore spaces

Two sentences wrap a literal that must not be translated (the `input` group
name, the `user@instance` example). Those are split around a placeholder
rather than interpolated as text, so a translation keeps control of the word
order on both sides of it. Keybind action labels gained a `labelKey`
alongside the English `label`, which stays for non-React callers.

Verified: web build (tsc + vite), desktop typecheck, 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:38:28 -03:00
devsyncwrldandClaude Opus 5 a54d998ba9 chore: version 1.4.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:52:31 -03:00
devsyncwrldandClaude Opus 5 860f02b2c1 feat(channels): rename channels, and quieter defaults for sound and streams
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Four owner requests in one pass.

Rename: the PATCH /api/channels/:id route already accepted a name and a
topic, and the API client already exposed it — the only thing missing was a
way to reach it. The channel settings Overview tab now edits both, gated on
MANAGE_CHANNELS like the server is, with Save/Discard appearing only when
the form is dirty. The server's lowercase-and-hyphenate rule moved to
@backspace/shared so the field can show what a name will actually become
instead of letting it change shape on save; one copy of the rule, used by
both sides.

Stream volume: someone else's screen share now starts at 50 instead of 100
(DEFAULT_STREAM_VOLUME). The publisher boosts system audio before sending,
so the old default arrived far too loud for a viewer who never touched the
slider. Only the default moves — the slider still covers the full range and
a value the user set is still persisted.

Sound effects: SFX_BASE_VOLUME 0.8 -> 0.4, halving every cue. The .ogg files
were left alone; they already peak below full scale, so the harshness was
level, not recorded distortion. Every SFX playback path goes through
getSfxVolume(), so this is the single knob.

Translations: channel settings (including the override editor's permission
labels), the friends page end to end — tabs, lists, requests, the add tab
and discovery cards — the invite modal including its per-invite failure
reasons, and the group-DM member actions. Failure reasons are carried as
translation keys rather than finished text, so results already on screen
re-label when the language changes.

Verified: web build (tsc + vite), server typecheck, 490 web tests, 804
server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:59:25 -03:00
devsyncwrldandClaude Opus 5 293378293d fix(ci): match release assets by name, and close the guard block
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two defects, both mine, and the second hid the first.

The cleanup built a regex and matched it against the jq line '<id> <name>', so
the anchor in latest.yml could never match — it sits after the id. Only the
.exe was removed, and each publish left another latest.yml behind. Gitea then
served the older one, so the updater kept reporting the previous version as
current: an update that exists and is never offered, with nothing logged
anywhere. Filtering now happens inside jq, comparing the name directly.

The guard meant to catch exactly that was missing its closing fi, so the step
died on a syntax error before reaching it. Verified with bash -n this time,
which is what should have happened before it ever ran on a runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:14:59 -03:00
devsyncwrldandClaude Opus 5 c5abb6fd64 chore: version 1.3.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Carries the silent native notification, which lives in the Electron main
process and therefore only takes effect from a new installer — the reason this
release is needed beyond exercising the restart prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:07:26 -03:00
devsyncwrldandClaude Opus 5 1fb61377b9 feat(notify): in-app notifications with the app's own sound
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The system balloon carries the OS notification sound, which does not belong to
this app, and it only fired while the window was out of focus — with the app
focused nothing appeared at all.

Notifications now surface inside the window, carry the same synthesised timbre
as the rest of the app's sounds, and clicking one opens the channel. The native
balloon is kept for when the window is not visible, since an in-app card
nobody can see is no notification, but it is now silent: the app plays its own
effect instead.

A focused window is notified only about other channels — announcing the
conversation someone is already reading is noise.

Also fixes the Gitea publish cleanup, which silently deleted nothing: it
interpolated an Actions expression inside a bash , and when the pattern
did not match, the loop passed over every asset. The release ended with two
latest.yml files and the updater served the older one, reporting 1.1.0 as
current — an update that exists but is never offered, with no error anywhere.
The filter is plain bash now, logs what it found, and the job fails if more
than one latest.yml survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:57:02 -03:00
devsyncwrldandClaude Opus 5 1a1067bbb6 chore: version 1.2.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Pins, filtered search, custom emojis and stickers, and the in-app restart
prompt. The prompt itself is only visible once an update is offered, so this
release is what makes it observable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:49:23 -03:00
devsyncwrldandClaude Opus 5 71d2109b2f feat(desktop): in-app prompt to restart for an update
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The main process already showed a system notification when an update finished
downloading, and nothing in the renderer listened — so the only cue vanished on
its own and was missed by anyone not watching the desktop at that moment.

A prompt now stays in the window until it is acted on or dismissed, with the
restart wired to the install-update channel the preload already exposed.

Dismissing is 'not now', not 'never': a later version reopens the prompt, since
the version it refers to has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:31:59 -03:00
devsyncwrldandClaude Opus 5 7f08384372 feat(expressions): pickers and upload for emojis and stickers
Completes the feature: the tables existed but nothing could be put in them.

Space settings gain an Emojis & Stickers panel behind MANAGE_SPACE, with a
512KB ceiling — both are fetched on every message that uses them, so weight
matters more than fidelity. The suggested name is pre-normalised so the common
case needs no typing, and a name collision reports itself distinctly from an
upload failure: the corrective action is different.

Custom emojis join the emoji picker as their own category. They have no native
character, so selecting one inserts :name: — the same text the renderer
resolves back to an image, which also means copying a message yields something
that still reads.

Stickers get a picker tab that only appears inside a space, since that is where
they exist, and send immediately on click: a sticker is the whole message, so
parking it in the composer to await Enter would make no sense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:25:10 -03:00
devsyncwrldandClaude Opus 5 7d003021d1 feat(expressions): custom emojis and stickers per space
Emojis and stickers get separate tables on purpose: an emoji sits inside a
sentence and a sticker is the whole message, so their sizes, limits and render
paths differ.

Emoji names are unique per space and restricted to letters, digits and
underscore — :name: has to resolve to one image, and a space or colon inside
the name would make the reference impossible to delimit. Sticker names are
labels picked from a grid, so they accept anything.

Rendering reuses the existing markdown pipeline by rewriting :name: into image
syntax, which needs no plugin. Code spans and fences are left alone: text
someone wrapped in backticks was meant to be shown literally, and turning it
into an image would destroy what they wrote.

Messages carry stickerId with ON DELETE set null — removing a sticker from the
space must not delete the history of everyone who used it. The server refuses a
sticker from another space, which would otherwise leak images between unrelated
servers.

Expressions load once per space and stay in memory: the message renderer
consults the map for every :name: it finds, and a request per lookup would turn
each message into a cascade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:57:32 -03:00
devsyncwrldandClaude Opus 5 50a8f12c77 feat(search): inline filters, and translate the search UI
The filters themselves already existed end to end — the server takes q, from,
has, before and after, the API client passes them, and the popover has fields
for each. What it lacked was discovery: the panel sits behind a button, so the
capability was invisible.

Typing 'de:fulano' or 'has:image' straight into the search box now applies the
same filters. Keys are accepted in both languages, since the app is bilingual,
and an unrecognised token falls back to being search text — otherwise a message
containing a URL or 'algo:coisa' would become unfindable.

Inline filters win over the panel's: whoever just typed one is expressing the
more recent intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:49:05 -03:00
devsyncwrldandClaude Opus 5 4ceb5cd66a feat(pins): pin messages to a channel
Pinned state lives on the message rather than a join table: a message is
pinned in exactly one channel, its own, so a separate table would add a join to
every lookup and buy nothing.

Every message now carries its pin state, so the timeline can mark a pin without
a second request and the panel and the timeline cannot disagree. Toggling is
deliberately not optimistic — the server refuses past the channel's limit, and
showing it pinned before confirmation would lie in exactly that case.

The pins list reuses the same assembly the channel history uses, extracted into
one helper, so the two cannot drift apart in what they include.

The migration also adds the (channel, user, created) index the filtered search
will need, since both touch the same table and one migration is cheaper than
two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:45:33 -03:00
devsyncwrldandClaude Opus 5 c4158ee245 docs: retire the fixes that shipped and were confirmed in use
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:13:30 -03:00
devsyncwrld 61c68761df i18n: translate the channel and member sidebars
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Clears the two remaining rounds owed under the fork's per-update translation
rule.

Covers channels, direct messages, the context menus, the inline voice settings
and the member list's loading and grouping labels.

The member grouping needed care: one function returns either a fixed label or
a role's own name, and role names are user data that must never be translated.
It now marks only the fixed cases and the component resolves them at render,
leaving role names untouched. The function is not a component, so it cannot
call the hook itself.
2026-09-01 17:03:48 -03:00
devsyncwrld c04b8b47eb i18n: translate the chat composer and messages
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Catching up on the fork's rule that every update also translates one existing
system — three updates went by without one.

The composer's placeholder takes the channel as a parameter rather than being
concatenated, since Portuguese puts the channel elsewhere in the sentence than
English does.

The translation hook is bound to `tr` in these two files, not `t`: both already
use `t` for transfer records in inner scopes, which shadowed the function and
made it uncallable exactly where it was needed.
2026-09-01 16:59:50 -03:00
devsyncwrld ce6bba0510 fix(ci): pulseaudio headers, and an ASCII release payload
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Linux got past pipewire and stopped on pulse/pulseaudio.h — the module
compiles both backends, so both sets of headers are needed.

Windows reached the upload and Gitea rejected the release creation with HTTP
422, 'invalid UTF-8 within /name': the JSON body carried accented text and the
Windows runner's shell mangles the encoding on the way out. The payload is now
ASCII, with a comment saying why, so it does not get 'improved' back into
Portuguese.
2026-09-01 16:04:01 -03:00
devsyncwrld 9ea399ded5 fix(ci): install pipewire headers, and make the Gitea upload diagnosable
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two separate failures on the first run.

Linux could not compile the native module: it needs libpipewire-0.3-dev, which
its README states and the apt list omitted.

Windows built everything, native module verified, then died on the upload with
a bare JSONDecodeError. The cause was curl -sf, which discards the error body,
so a failed release creation surfaced as an empty pipe and no reason at all.
The step now captures status and body and prints them, treats a failed create
as possibly the other matrix job having just created it, and passes
target_commitish so the tag can be created.

Embedded python gave way to jq: a multi-line heredoc inside a YAML literal
block ends the block at the first unindented line, which is how the file became
invalid YAML in the first place.
2026-09-01 15:58:26 -03:00
devsyncwrld 9f7723d104 ci: build on GitHub, publish updates from Gitea
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
GitHub stays the build machine — it has the Windows runners the native audio
module needs — but the update feed moves to this fork's own Gitea.

The GitHub repository is private, and electron-updater against a private GitHub
repo needs a token inside the shipped app, which is a leaked token. Gitea
serves release assets to anyone, so the installer carries no credential. The
flow was verified end to end against the live instance before writing this:
create release, upload asset, download anonymously.

The release tag is fixed at 'latest' because electron-updater fetches
latest.yml before it knows which version exists, so the URL cannot carry a
version; CI replaces that release's assets each publish. electron-builder runs
with --publish never since it cannot upload to Gitea, but still emits the
latest.yml the updater reads.

Needs a GITEA_TOKEN secret on the GitHub repository.
2026-09-01 15:49:25 -03:00
devsyncwrld 5b84843217 fix(screenshare): make shared system audio audible
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Listeners had to run the participant at 200% with attenuation off. Two causes,
both on the publishing side.

WASAPI returns the process mix at a level well below what a microphone track
arrives at, so the track was quiet before it ever left the machine. A gain
stage now boosts it, followed by a limiter — a compressor with a high ratio and
fast attack — so the boost cannot clip loud passages.

And the track was published with LiveKit's defaults, which are tuned for
speech: mono, low bitrate, and DTX enabled, which stops transmitting during
silence. On game and music audio that reads as thin and clipped. It now
publishes with the music stereo preset, stereo forced, and DTX and RED off.

Lives in packages/web, so it reaches the desktop app through a normal deploy —
no new installer.
2026-09-01 15:36:55 -03:00
devsyncwrld bd8decb4b3 fix(ci): actually compile the native audio module, and prove it
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The previous run went green and produced an installer with no system-audio
capture in it — the exact silent failure this module exists to prevent.

Two causes. electron-rebuild takes -w as one comma-separated list, not a
repeated flag; passing it twice made argv.w an array and the CLI threw
'argv.w.split is not a function', which also broke uiohook-napi's rebuild that
had been working. And pnpm 10 refuses to run a dependency's build script unless
it is listed in onlyBuiltDependencies, so node-gyp never ran for it at all —
the log said 'Ignored build scripts' and nothing else complained.

Neither surfaced because desktop's postinstall ends in , which
exists so contributors without build tools can install. That is reasonable
locally and dangerous in CI, so the workflow now asserts a compiled .node
exists and fails loudly when it does not, instead of trusting an exit code that
was designed to lie.
2026-09-01 15:13:26 -03:00
devsyncwrld 52f43d4d61 build: record electron-native-screenshare in the lockfile
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The dependency was added to packages/desktop/package.json without regenerating
the lockfile, so CI's --frozen-lockfile refused the install — correctly: a
build that resolves fresh versions is a build that differs from what was
tested.
2026-09-01 15:06:59 -03:00
devsyncwrld 7eaba5e3f3 ci: build only the architectures this group runs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Windows arm64 doubled the slowest job in the matrix — two Electron
distributions downloaded, native modules compiled twice — and nobody here runs
Windows on ARM. Linux arm64 went for the same reason.

Removing arm64 also retires the fpm step: electron-builder's bundled fpm is
x86_64-only and failed on the arm64 runner, which is the only reason a native
fpm was installed (ruby, ruby-dev, build-essential and a gem with native
extensions, on every run). On an x86_64 runner the bundled one works.

Matrix is now Windows x64 and Linux x64.
2026-09-01 14:02:54 -03:00
devsyncwrld 124db82a0c ci: drop the macOS build from the release matrix
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Nobody in this group runs macOS, and on a private repository the macOS runner
bills at 10x — it was the most expensive platform in the matrix, compiled on
every tag for zero users.

This edits a file inherited from upstream, so it will conflict on merges. That
is the accepted cost: the alternative is paying for a macOS build indefinitely.
2026-09-01 14:00:12 -03:00
devsyncwrld b4d001eb26 ci: add a fast Windows-only desktop build
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml builds four platforms and publishes a release, which is right for a
release and slow for 'give me an exe to test'. Wall time is set by the Windows
job, which compiles native modules and downloads Electron twice because it
targets x64 and arm64 together — and Windows-on-ARM is not something this group
runs.

This path builds one architecture, caches the Electron and electron-builder
downloads (~100MB re-fetched every run otherwise), and uploads the installer as
an artifact, so it needs neither a tag nor a version bump.

Kept as a separate file: release.yml comes from upstream and takes merges, so
editing it would conflict on every update.

Cost matters too on a private repository: macOS runners bill at 10x and Windows
at 2x, so the full matrix was paying for macOS builds on every tag.
2026-09-01 13:29:18 -03:00
devsyncwrld 60c51a1635 chore: version 1.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
First release built from the fork. electron-builder names artifacts and the
GitHub release from package.json rather than the git tag, so the two have to
agree or electron-updater compares mismatched versions and never offers the
update. v1.0.0 is already taken by upstream's tag in this history.
2026-09-01 13:15:13 -03:00
devsyncwrld d208b0c277 build(desktop): publish releases to syncwrld/resenhacord
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:14:43 -03:00
devsyncwrld 2f836cd366 build(desktop): correct the fork's GitHub owner to syncwrld
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:11:49 -03:00
devsyncwrld f7f2cf75ab build(desktop): point releases at the fork and rebuild the new native module
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml already builds and publishes desktop installers for every platform
on a v* tag, so no new workflow was needed — it needed adapting to the fork.

electron-builder published to upstream's repository, so releases (and with them
the electron-updater feed, which had no source at all) went nowhere useful.

More consequential: postinstall ran electron-rebuild against uiohook-napi only.
electron-native-screenshare would have been compiled for Node's ABI rather than
Electron's and failed to load at runtime — and since the loader degrades
quietly by design, the symptom would have been screen sharing with no sound and
no error, which is the exact bug this module exists to fix.

Documented in docs/build-desktop.md, including why the build has to run on
GitHub (Gitea Actions provides no hosted runners, and the module needs MSVC)
and why the Windows runner stays pinned to windows-2022.
2026-09-01 13:10:10 -03:00
devsyncwrld 3deed92dd9 feat(sounds): replace call and stream effects
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The originals were all 1.14s — long enough to be intrusive for events that
fire whenever anyone joins, leaves, or watches a stream.

Synthesised rather than sourced: no third-party file, so no licensing question
in a public repository. The parameters are measured, not guessed — envelope,
spectral peaks and decay taken from two reference sounds the instance owner
supplied. The timbre is fundamental plus octave at near-equal strength
(1.00 / 0.85 / 0.10 / 0.02) decaying to 1/e in 0.19s, with no reverb.

Call join rises C4→G4, leave falls D4→G3, matching the references' intervals
and their 100ms spacing. The stream pair reuses those resolution notes as
single tones at ~55% the volume: they fire far more often during a broadcast,
so they have to sit under the call sounds rather than beside them.

Stream start is the one event that happens once per broadcast, so it can be a
chord — with a low thump and a short air layer that both die inside 150ms,
adding weight without length. Stream end mirrors it descending, quieter, and
without the air, since brightness reads as arrival.

Generators and the measured parameters are kept in tools/sfx so these can be
retuned without redoing the analysis. Total size drops from 150KB to 41KB.
2026-09-01 13:01:06 -03:00
devsyncwrld d525bbb8c5 feat(desktop): capture system audio with process isolation
Electron's audio: 'loopback' captures the whole output mix, this app's own
playback included — so everyone else's voices went back out inside the share
and each listener heard themselves. Not acoustic echo but a digital copy of
the output, which is why headphones never helped, and why shareAudio already
defaulted to off in the desktop app.

Electron offers no way to exclude our own audio: the docs allow only
'loopback' or 'loopbackWithMute', and the handler discards the renderer's
constraints (restrictOwnAudio never arrives). electron-native-screenshare does
it at the OS level — WASAPI process loopback on Windows — capturing only the
shared window when its pid resolves, and otherwise everything except us.

The module hands raw PCM to the main process, so it crosses IPC and is
scheduled onto a running cursor in Web Audio to become a MediaStreamTrack,
published as ScreenShareAudio. Loading is optional and failure degrades to a
silent share rather than blocking the app or the screen share.

The browser path is untouched: Chrome honours restrictOwnAudio and has no echo.

Verified by typecheck (web and Electron main) and the web suite. The audio path
itself cannot be exercised here — no Windows, no Electron, no audio device.
2026-09-01 00:56:34 -03:00
devsyncwrld 310e9b86be docs: diagnose the screen-share audio echo
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:58:59 -03:00
271 changed files with 30599 additions and 1340 deletions
+92
View File
@@ -0,0 +1,92 @@
# Compila o APK do Android no GitHub e o devolve como artefato.
#
# O runner entra só como máquina de build, pelo mesmo motivo do desktop: a VPS
# tem ~2 GB de RAM livres e roda a produção, então um Gradle com o SDK do
# Android ali dentro arrisca derrubar as chamadas de quem está usando o app.
#
# O APK sai **sem assinatura**. A chave de assinatura fica na VPS e não sobe
# para cá: quem assina é /opt/ops/release-android.sh, depois de baixar este
# artefato. Um APK assinado é instalável por qualquer um, e a chave é o que
# prova que uma atualização veio de nós — ela não tem por que existir em duas
# máquinas.
name: Android APK
on:
workflow_dispatch:
push:
tags: ['android-v*']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
# cache/cache-dependency-path apontando para o npm deste pacote não é só
# otimização: sem eles o setup-node lê o campo `packageManager` do
# package.json da raiz, tenta usar pnpm — que este workflow não instala —
# e falha antes de rodar qualquer coisa.
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 22
cache: npm
cache-dependency-path: packages/mobile/package-lock.json
# npm, não pnpm: packages/mobile fica fora do workspace de propósito
# (ver pnpm-workspace.yaml). Ele tem o próprio package-lock.json.
- name: Install Capacitor
working-directory: packages/mobile
run: npm ci
# Copia a config e os arquivos de public/ para dentro do projeto nativo.
# Sem isso o APK sai com o que estiver commitado em assets/, que pode
# estar velho — esses arquivos são gerados, não versionados.
- name: Sync Capacitor
working-directory: packages/mobile
run: npx cap sync android
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('packages/mobile/android/**/*.gradle', 'packages/mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Build unsigned release APK
working-directory: packages/mobile/android
run: ./gradlew assembleRelease --no-daemon --stacktrace
# O nome do arquivo muda conforme a versão do AGP (app-release-unsigned.apk
# ou app-release.apk quando não há config de assinatura). Localiza em vez
# de supor, e falha alto se nada saiu — um artefato vazio só apareceria
# como erro lá na frente, na hora de assinar.
- name: Collect APK
run: |
set -euo pipefail
mkdir -p out
found=$(find packages/mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
[ -n "$found" ] || { echo "::error::o build não produziu APK nenhum"; exit 1; }
cp "$found" out/resenhacord-unsigned.apk
ls -l out/
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: resenhacord-apk-unsigned
path: out/resenhacord-unsigned.apk
retention-days: 14
if-no-files-found: error
+97
View File
@@ -0,0 +1,97 @@
# Caminho rápido: só Windows x64, disparado à mão.
#
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
# publica release e alimenta o electron-updater). Este aqui existe para quando
# você só quer um .exe para testar, sem marcar versão.
#
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
# mexer nele criaria conflito a cada atualização.
name: Build Windows (rápido)
on:
workflow_dispatch:
inputs:
arch:
description: Arquitetura
required: false
default: x64
type: choice
options: [x64, arm64]
permissions:
contents: read
jobs:
build:
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
# módulos nativos. Mesma razão documentada no release.yml.
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para que quem não tem
# ferramentas de build consiga instalar. No CI isso transforma falha em
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
# resultado em vez de confiar no código de saída.
- name: Verify native audio module compiled
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
if [ -z "$found" ]; then
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
exit 1
fi
echo "OK:"; echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: sem release, sem precisar de tag nem bump de versão.
# O instalador sai como artefato desta execução.
- name: Build installer
working-directory: packages/desktop
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: backspace-windows-${{ inputs.arch || 'x64' }}
path: packages/desktop/dist-electron/*.exe
retention-days: 14
if-no-files-found: error
+221
View File
@@ -0,0 +1,221 @@
# Compila no GitHub, publica no Gitea.
#
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
# credencial embutida no app, o que aconteceria com um repositório privado no
# GitHub.
name: Publicar no Gitea
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Tag a publicar (ex. v1.1.0)
required: true
type: string
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
args: --win --x64
- os: ubuntu-latest
args: --linux --x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libx11-dev libxtst-dev libxt-dev \
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev \
libpipewire-0.3-dev libpulse-dev
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
# de build. No CI isso esconde falha: o instalador sairia sem captura de
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
- name: Verify native audio module compiled
if: runner.os == 'Windows'
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
# passo seguinte faz o upload.
- name: Build installers
working-directory: packages/desktop
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TAG: latest
run: |
set -uo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::segredo GITEA_TOKEN não configurado"
exit 1
fi
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
# engole a resposta de erro, então uma falha aqui só aparecia como um
# JSONDecodeError sem dizer o motivo.
api() {
local method=$1 path=$2; shift 2
curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
}
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
# jq em vez de python embutido: um heredoc multilinha dentro de um
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
json_id() { jq -r '.id // empty'; }
find_release() {
local r; r=$(api GET "/releases/tags/$TAG")
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
}
# O corpo do POST fica em ASCII de proposito: o shell do runner
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
# "invalid UTF-8 within /name" (HTTP 422).
ID=$(find_release)
if [ -z "$ID" ]; then
R=$(api POST "/releases" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
if [ "$(code "$R")" = "201" ]; then
ID=$(body "$R" | json_id)
echo "release criada: $ID"
else
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
# O outro job da matriz pode tê-la criado no mesmo instante.
ID=$(find_release)
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
echo "release encontrada após corrida: $ID"
fi
else
echo "release existente: $ID"
fi
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
# não apagarem o trabalho um do outro.
#
# Padrão montado em bash puro: a versão anterior interpolava uma
# expressão do Actions dentro de um `case`, e quando ela não casou o
# laço passou em silêncio — a release ficou com dois latest.yml e o
# updater serviu o antigo, dizendo que a versão nova não existia.
R=$(api GET "/releases/$ID/assets")
if [ "$(code "$R")" != "200" ]; then
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
# Filtro dentro do jq, comparando o nome direto. A versão anterior
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
# podia casar. O laço então só removia os .exe e deixava um latest.yml
# duplicado, que é o arquivo que decide se há atualização.
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
.[]
| select(
if $os == "Windows"
then (.name | endswith(".exe")) or .name == "latest.yml"
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
end
)
| "\(.id) \(.name)"')
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
if [ -n "$OLD" ]; then
printf '%s\n' "$OLD" | while read -r aid aname; do
[ -n "$aid" ] || continue
echo "removendo anexo antigo: $aname"
D=$(api DELETE "/releases/$ID/assets/$aid")
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
done
fi
shopt -s nullglob
sent=0
for f in packages/desktop/dist-electron/*.exe \
packages/desktop/dist-electron/*.AppImage \
packages/desktop/dist-electron/*.deb \
packages/desktop/dist-electron/latest*.yml; do
name=$(basename "$f")
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
if [ "$(code "$R")" != "201" ]; then
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
echo "enviado: $name"
sent=$((sent+1))
done
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
echo "$sent arquivo(s) publicados"
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
# uma release que parece boa e não atualiza.
if [ "${RUNNER_OS:-}" = "Windows" ]; then
R=$(api GET "/releases/$ID/assets")
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
if [ "$DUP" != "1" ]; then
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
exit 1
fi
echo "latest.yml: 1 cópia, como esperado"
fi
+11 -22
View File
@@ -14,22 +14,26 @@ jobs:
fail-fast: false
matrix:
include:
# macOS — universal build (arm64 + x64) on Apple Silicon runner
- os: macos-latest
args: --mac --arm64 --x64
# Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
# dobrava o job mais lento (duas distribuições do Electron, módulos
# nativos compilados duas vezes) e ninguém roda Windows em ARM.
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
# instalava o fpm nativo perdeu a razão de existir: ele só era
# necessário porque o fpm embutido no electron-builder é x86_64 e
# falhava no runner arm64.
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
# é cobrado a 10x num repositório privado — era a plataforma mais cara
# da matriz, compilada em toda tag para zero usuários.
# Windows — x64 + arm64 on x64 runner.
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
# ships VS "18", which the node-gyp bundled with electron-rebuild
# cannot detect ("unknown version undefined") when compiling
# uiohook-napi in postinstall.
- os: windows-2022
args: --win --x64 --arm64
args: --win --x64
# Linux — x64 on x64 runner
- os: ubuntu-latest
args: --linux --x64
# Linux — arm64 on arm64 runner
- os: ubuntu-24.04-arm
args: --linux --arm64
runs-on: ${{ matrix.os }}
@@ -59,17 +63,6 @@ jobs:
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev
- name: Install fpm for .deb packaging
if: runner.os == 'Linux'
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
# aborts the .deb target with "cannot execute binary file: Exec format
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
# arches package their .deb with a host-native fpm. ruby-dev + make are
# needed for fpm's native gem dependencies.
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
@@ -97,7 +90,3 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# Use the host-native fpm installed above instead of electron-builder's
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
# macOS/Windows, which don't build .deb.
USE_SYSTEM_FPM: "true"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+32 -25
View File
@@ -1,33 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 147.46 146.59">
<!--
Resenhacord app icon: the mark on the app's own night-blue ground (#0b0b10,
the theme colour), inside the 22 %-radius rounded square the icon pipeline
masks to. A soft violet glow behind the mark keeps the badge from reading as
a flat black square at large sizes.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
.cls-2 {
fill: url(#Unbenannter_Verlauf_515);
}
.cls-3 {
fill: url(#Unbenannter_Verlauf_547);
}
</style>
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="62.13" y1="77.74" x2="118.16" y2="77.74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<radialGradient id="glow" cx="512" cy="480" r="420" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7c6cf6" stop-opacity="0.28"/>
<stop offset="1" stop-color="#7c6cf6" stop-opacity="0"/>
</radialGradient>
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c4b5fd"/>
<stop offset="1" stop-color="#7c6cf6"/>
</linearGradient>
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="9.21" y1="71.45" x2="135.41" y2="71.45" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fda4af"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<rect class="cls-1" x="0" y="0" width="147.46" height="146.59" rx="32.42" ry="32.42"/>
<g>
<path class="cls-2" d="M100.85,75.32c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-3" d="M102.43,119.43c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
<rect width="1024" height="1024" rx="225" fill="#0b0b10"/>
<rect width="1024" height="1024" rx="225" fill="url(#glow)"/>
<g transform="translate(192 192) scale(6.4)">
<path mask="url(#gap)" fill="url(#back)"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="url(#front)"/>
<g fill="#fff">
<circle cx="58" cy="62" r="3.6"/>
<circle cx="68" cy="62" r="3.6"/>
<circle cx="78" cy="62" r="3.6"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+16 -10
View File
@@ -1,14 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
<!-- Monochrome silhouette of mark.svg for macOS menu-bar templates (alpha only). -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
</style>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
<mask id="dots">
<rect width="100" height="100" fill="#fff"/>
<circle cx="58" cy="62" r="3.6" fill="#000"/>
<circle cx="68" cy="62" r="3.6" fill="#000"/>
<circle cx="78" cy="62" r="3.6" fill="#000"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
</g>
<path mask="url(#gap)" fill="#1d1d1b"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="#1d1d1b" mask="url(#dots)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+26 -21
View File
@@ -1,28 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 123.07 100.13">
<!--
Resenhacord mark: two speech bubbles in conversation, the front one talking.
Square viewBox so every raster target (tray, favicon, PWA) scales without
letterboxing. The gap between the bubbles is cut with a mask rather than
painted, so the mark stays legible on any background and the macOS template
(alpha-only) variant keeps the same silhouette.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<style>
.cls-1 {
fill: url(#Unbenannter_Verlauf_515);
}
.cls-2 {
fill: url(#Unbenannter_Verlauf_547);
}
</style>
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="52.91" y1="56.35" x2="108.94" y2="56.35" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c4b5fd"/>
<stop offset="1" stop-color="#7c6cf6"/>
</linearGradient>
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="0" y1="50.06" x2="126.19" y2="50.06" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fda4af"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<g>
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-2" d="M93.21,98.04c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
</g>
<path mask="url(#gap)" fill="url(#back)"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="url(#front)"/>
<g fill="#fff">
<circle cx="58" cy="62" r="3.6"/>
<circle cx="68" cy="62" r="3.6"/>
<circle cx="78" cy="62" r="3.6"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+56
View File
@@ -0,0 +1,56 @@
# Gerar o instalador do app desktop
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
ao empurrar uma tag `v*`.
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
## Por que GitHub e não Gitea
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
WASAPI e só compila no Windows, com o compilador da Microsoft.
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
espelho para compilar.
## Passos (uma vez)
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
2. Adicionar o espelho e empurrar:
```
cd /opt/backspace
git remote add github git@github.com:syncwrld/resenhacord.git
git push github main
```
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
```
git tag v1.0.1 && git push github v1.0.1
```
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
## O que isso resolve de quebra
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
precisa reinstalar na mão de novo.
## Adaptações feitas para o fork
| O quê | Por quê |
|---|---|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
## Detalhe herdado que vale preservar
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
undefined"). Não mude isso sem testar.
+77 -9
View File
@@ -21,11 +21,29 @@ código e os commits seguem em inglês, como o resto do repositório.
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Rebrand: **Resenhacord** + logo nova | (ver git log) | `productName` mudou, `appId` **não** — assim o NSIS troca a instalação antiga no lugar. `userData` migra `Backspace` → `Resenhacord` no primeiro boot (senão todo mundo cairia da sessão). Logo: duas bolhas de conversa, roxo → rosa; fonte em `assets/brand/*.svg`, PNGs e ícones gerados por `pnpm gen-icons` |
| Aviso "X fixou uma mensagem" | (ver git log) | Tabela `messages` ganhou `type` (migração 0018); a rota de pin posta uma mensagem de sistema. Dois links: pular pra mensagem e abrir o painel de fixadas |
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
## Já existia no código (verificado, não construir de novo)
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
client de API e painel no `SearchPopover` já existiam completos. Só faltava
tradução, sintaxe embutida (`de:fulano`) e descoberta.
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
escalonados em 0/150/300ms.
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
@@ -47,12 +65,33 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| # | Feature | Tamanho | Observação técnica |
|---|---|---|---|
## Identidade do perfil (entregue 2026-09-02, pedido: "completo e FODA")
Uma coluna JSON (`users.profile_style`, migração 0019) normalizada no servidor
por `shared/profile.ts`. Aba **Perfil** nas configurações com prévia ao vivo
(`ProfilePanel` renderiza o mesmo `ProfileCard` que o popout).
| Peça | Onde aparece | Detalhe |
|---|---|---|
| Estilo do nome — 10 fontes × 9 efeitos × 2 cores | popout, perfil completo | Fontes OFL em `public/fonts`; efeitos são CSS (`.name-fx-*`) |
| Decoração do avatar — 10 | popout, perfil, editor | SVG inline em `DecoratedAvatar`, sem imagens |
| Efeito do perfil — 9 | banner do popout e do perfil | Partículas CSS com posições semeadas (`ProfileEffectLayer`) |
| Nameplate — 10 + personalizado | lista de membros | `.nameplate-*` |
| Tema (2 cores) | fundo do cartão e banner sem imagem | |
| Pronomes, tag (emoji + 2–4 letras) | ao lado do nome; tag também na lista de membros | |
| Links (até 5) | popout e aba Sobre | Ícone detectado pelo host; só https |
| Quadro: jogo favorito, rotação (5), quero jogar (10), citação | aba Quadro do perfil; tira "Coleção" no popout | Busca na Steam via `/api/games/search` (proxy, cache 10 min); capas só de CDN da Steam |
| Emblemas derivados | popout e perfil | admin, dono, fundador (1º mês), veterano (1 ano), OG (6 meses), estiloso — nada é armazenado |
Fora do escopo desta entrega, candidatos a seguir: perfil por servidor
(apelido/avatar por servidor), emblemas concedidos pelo admin, molduras
animadas por upload, status com expiração.
## Pendente — ideias aprovadas
| Feature | Tamanho | Observação técnica |
|---|---|---|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
## Regra de idioma (a partir de 2026-08-31)
@@ -72,21 +111,52 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
- Configurações → Privacidade
- Configurações → Conexões (nasceu bilíngue)
- Seletor de GIF
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento)
- Busca (rótulos, filtros e sintaxe embutida)
- Registro de auditoria e Estatísticas (nasceram bilíngues)
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
- Modal de convite (incluindo os motivos de falha por convite)
- Lista de membros da DM em grupo (remover e transferir propriedade)
- Configurações → Área de trabalho (início automático, atualizações, instância)
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
- Explorar servidores (cartões, entrar, pedir para entrar)
- Configurações → Voz: vídeo (prévia, permissões, escolha de câmera) — fecha a aba
- Criar canal, categoria e servidor
- DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair)
- Entrar num servidor (descoberta, código de convite, conectar e login federado)
- Excluir conta, transferir propriedade e a tela de erro geral
- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo)
- Configurações do servidor → Membros e banimentos
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
- Configurações da categoria (renomear, privacidade, exceções, excluir)
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
- Mensagens de sistema do chat e a prévia delas na barra lateral
- Login, registro e a página de convite
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
- Telas do celular (`Mobile*.tsx`)
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
pedidos de entrada
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
de cargos e pelos editores de exceção de canal e de categoria — antes havia
duas cópias da mesma lista.
### Fila sugerida de tradução
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
modais de convite · configurações restantes · telas de erro
Configurações → Instância: registro, federação, transmissão, armazenamento e
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
## Correções pendentes
| Problema | Causa provável | Correção |
|---|---|---|
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
## App desktop — já existia (verificado 2026-08-31)
@@ -111,8 +181,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
+26
View File
@@ -0,0 +1,26 @@
# Scripts de operação da instância
Rodam na VPS, em `/opt/ops`, onde os arquivos são links simbólicos para esta
pasta — o repositório é a única cópia de verdade. Antes disso eles existiam só
na VPS, sem versão nem backup: uma reinstalação do servidor levaria junto todo
o processo de publicação.
| script | o que faz |
| --- | --- |
| `call-check.sh` | Diz se tem alguém em call. Roda antes de reiniciar o servidor. |
| `release-desktop.sh` | Espelha o fork no GitHub e dispara a compilação dos instaladores de desktop. |
| `release-android.sh` | Compila o APK no GitHub, assina na VPS e publica no Gitea. |
## O que estes scripts leem e não está aqui
Nada de segredo entra no repositório. Os arquivos abaixo ficam só na VPS, com
permissão `600`:
- `/root/.github-token` — dispara e baixa os builds do GitHub.
- `/root/.gitea-token` — publica os anexos das releases.
- `/root/android-signing/` — a chave que assina o APK, mais a senha dela.
A chave do Android merece atenção especial: ela é o que prova ao Android que
uma atualização veio de nós. Se for perdida, ninguém consegue atualizar por
cima — todo mundo tem que desinstalar e instalar de novo, e não existe
recuperação. Mantenha uma cópia fora da VPS.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Reports how many people are currently in a call on this instance.
#
# Asks LiveKit directly (ListRooms over its twirp API) rather than inferring
# from the app: LiveKit is the authority on who is actually connected to media.
# Prints the participant count and exits 0 when the instance is idle, 1 when
# someone is in a call — so it can gate a deploy.
set -euo pipefail
ENV_FILE=${ENV_FILE:-/opt/backspace/.env}
# `|| true`: under `set -o pipefail` a grep with no match aborts the script
# before the check below, exiting 1 — which in this script's contract means
# "someone is in a call" rather than "I could not tell".
KEY=$(grep -E '^LIVEKIT_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
SECRET=$(grep -E '^LIVEKIT_API_SECRET=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
[ -n "$KEY" ] && [ -n "$SECRET" ] || { echo "LiveKit credentials not found in $ENV_FILE" >&2; exit 2; }
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
now=$(date +%s)
header=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64url)
payload=$(printf '{"iss":"%s","sub":"%s","nbf":%d,"exp":%d,"video":{"roomList":true}}' \
"$KEY" "$KEY" "$now" "$((now + 60))" | b64url)
sig=$(printf '%s.%s' "$header" "$payload" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)
jwt="$header.$payload.$sig"
resp=$(curl -sS --max-time 10 -w '\n%{http_code}' -X POST http://127.0.0.1:7880/twirp/livekit.RoomService/ListRooms \
-H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' -d '{}') || {
echo "LiveKit unreachable — refusing to report idle" >&2; exit 2; }
code=$(printf '%s' "$resp" | tail -n1)
resp=$(printf '%s' "$resp" | sed '$d')
# A failed query returns no participant counts, which would read as "idle" and
# green-light a deploy on top of a live call. Anything but a well-formed 200 is
# treated as unknown, not as empty.
if [ "$code" != "200" ] || ! printf '%s' "$resp" | grep -q '"rooms"'; then
echo "LiveKit answered HTTP $code without a room list — status unknown, not deploying" >&2
exit 2
fi
# numParticipants is omitted by protobuf JSON when zero, so absence means empty.
total=$(printf '%s' "$resp" | grep -oE '"numParticipants":[0-9]+' | cut -d: -f2 | paste -sd+ - | bc 2>/dev/null || true)
total=${total:-0}
if [ "$total" -gt 0 ]; then
echo "EM CALL: $total participante(s) — nao deployar"
exit 1
fi
echo "OCIOSO: ninguem em call"
exit 0
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Baixa o APK compilado no GitHub, assina aqui e publica no Gitea.
#
# A chave de assinatura fica nesta VPS e nao sobe para o GitHub. Ela e o que
# prova que uma atualizacao veio de nos: um APK assinado com outra chave nao
# instala por cima do instalado, o Android recusa. Por isso o runner entrega o
# APK sem assinatura e a assinatura acontece aqui.
#
# Uso: release-android.sh [run_id]
# sem run_id, dispara um build novo e espera terminar.
set -euo pipefail
REPO=${REPO:-/opt/backspace}
GH_REPO=${GH_REPO:-syncwrld/resenhacord}
TOOLS=/opt/ops/android-tools/build-tools
KEYSTORE=/root/android-signing/resenhacord.keystore
PASSFILE=/root/android-signing/password.txt
OUT=/opt/ops/android-out
GITEA_API=https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TOKEN=$(cat /root/.github-token)
gh() { curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
# apksigner e um script que chama java, e esta VPS nao tem JDK instalado (nem
# deve ter: ela roda a producao, nao compila nada). Roda num container, com as
# ferramentas, a chave e a pasta de saida montadas. zipalign e binario nativo e
# roda direto no host.
apksigner() {
docker run --rm \
-v "$TOOLS":/tools:ro \
-v /root/android-signing:/ks:ro \
-v "$OUT":/out \
eclipse-temurin:21-jdk /tools/apksigner "$@"
}
for f in "$TOOLS/apksigner" "$TOOLS/zipalign" "$KEYSTORE" "$PASSFILE"; do
[ -e "$f" ] || { echo "faltando: $f" >&2; exit 1; }
done
RUN_ID=${1:-}
if [ -z "$RUN_ID" ]; then
echo "==> Disparando o build no GitHub"
gh -o /dev/null -X POST \
"https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/dispatches" \
-d '{"ref":"main"}'
sleep 10
RUN_ID=$(gh "https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/runs?per_page=1" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['workflow_runs'][0]['id'])")
echo " run=$RUN_ID"
echo "==> Esperando (uns 4 min)"
for _ in $(seq 1 90); do
read -r status conclusion < <(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID" \
| python3 -c "import sys,json;r=json.load(sys.stdin);print(r['status'],r['conclusion'])")
[ "$status" = "completed" ] && break
sleep 20
done
[ "${conclusion:-}" = "success" ] || { echo "build falhou: ${conclusion:-timeout}" >&2; exit 1; }
fi
echo "==> Baixando o APK sem assinatura"
mkdir -p "$OUT"
URL=$(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" \
| python3 -c "import sys,json;a=json.load(sys.stdin)['artifacts'];print(a[0]['archive_download_url'] if a else '')")
[ -n "$URL" ] || { echo "o run $RUN_ID nao tem artefato" >&2; exit 1; }
curl -sL -H "Authorization: Bearer $TOKEN" "$URL" -o "$OUT/apk.zip"
rm -f "$OUT/resenhacord-unsigned.apk"
unzip -o -q "$OUT/apk.zip" -d "$OUT"
VERSION=$(python3 -c "import json;print(json.load(open('$REPO/package.json'))['version'])")
SIGNED="$OUT/Resenhacord-$VERSION.apk"
echo "==> Alinhando e assinando $VERSION"
# --key-pass omitido de proposito: sem ele o apksigner reusa a senha do
# keystore, que e a mesma. Passar o mesmo arquivo nas duas opcoes falha — ele le
# os arquivos de senha em sequencia e bate no fim do arquivo na segunda leitura.
# zipalign antes do apksigner, nao depois: o alinhamento move bytes dentro do
# zip e invalidaria a assinatura se viesse por ultimo.
"$TOOLS/zipalign" -p -f 4 "$OUT/resenhacord-unsigned.apk" "$OUT/aligned.apk"
apksigner sign \
--ks /ks/resenhacord.keystore --ks-pass file:/ks/password.txt \
--ks-key-alias resenhacord \
--out "/out/$(basename "$SIGNED")" /out/aligned.apk
rm -f "$OUT/aligned.apk" "$OUT/apk.zip"
echo "==> Conferindo a assinatura"
apksigner verify --print-certs "/out/$(basename "$SIGNED")" | head -5
if [ "${1:-}" = "--no-publish" ] || [ "${PUBLISH:-1}" = "0" ]; then
echo "APK pronto: $SIGNED"
exit 0
fi
echo "==> Publicando no Gitea"
GITEA_TOKEN=$(cat /root/.gitea-token 2>/dev/null || true)
if [ -z "$GITEA_TOKEN" ]; then
echo "sem /root/.gitea-token — APK pronto localmente em $SIGNED"
exit 0
fi
api() { curl -s -w '\n%{http_code}' -X "$1" -H "Authorization: token $GITEA_TOKEN" "$GITEA_API$2" "${@:3}"; }
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
R=$(api GET "/releases/tags/latest")
ID=$(body "$R" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
[ -n "$ID" ] || { echo "release 'latest' nao encontrada no Gitea" >&2; exit 1; }
# Remove o APK anterior: dois anexos com o mesmo nome fazem o Gitea servir o
# mais antigo, e quem baixasse pegaria a versao velha sem erro nenhum.
api GET "/releases/$ID/assets" | sed '$d' \
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
| while read -r aid; do
[ -n "$aid" ] && api DELETE "/releases/$ID/assets/$aid" >/dev/null && echo " removido anexo $aid"
done
NAME=$(basename "$SIGNED")
R=$(api POST "/releases/$ID/assets?name=$NAME" -F "attachment=@$SIGNED")
[ "$(code "$R")" = "201" ] || { echo "falha no upload — HTTP $(code "$R"): $(body "$R")" >&2; exit 1; }
echo "publicado: $NAME"
echo "https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/$NAME"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Espelha o fork no GitHub e dispara a compilacao dos instaladores.
#
# O GitHub e usado apenas como maquina de build: ele fornece runners Windows
# prontos, e o modulo nativo do audio (WASAPI) so compila la. O Gitea continua
# sendo o repositorio principal.
set -euo pipefail
REPO=${REPO:-/opt/backspace}
TAG=${1:-}
cd "$REPO"
if [ -z "$TAG" ]; then
echo "uso: $0 vX.Y.Z" >&2
exit 2
fi
# `ssh -T git@github.com` always exits 1 (GitHub refuses shell access), and
# under `pipefail` that status wins over grep's — so piping straight into the
# `if` reported failure even on a working key. Capture, then test.
SSH_PROBE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -T git@github.com 2>&1 || true)"
if ! printf '%s' "$SSH_PROBE" | grep -q "successfully authenticated"; then
echo "GitHub ainda nao aceita a chave desta VPS." >&2
echo "Adicione esta chave em https://github.com/settings/keys :" >&2
cat ~/.ssh/id_ed25519.pub >&2
exit 1
fi
echo "==> Espelhando main no GitHub"
git push github main
echo "==> Marcando $TAG (e a tag que dispara o workflow release.yml)"
git tag -f "$TAG"
git push -f github "$TAG"
echo
echo "Compilacao disparada. Acompanhe em:"
echo " https://github.com/syncwrld/resenhacord/actions"
echo "Os instaladores aparecem em Releases quando terminar (~15 min)."
+4 -3
View File
@@ -1,8 +1,8 @@
{
"name": "backspace",
"version": "1.0.0",
"version": "2.1.0",
"private": true,
"description": "Open, self-hosted communication platform — text, voice, video, and federation",
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only",
"author": "Jannis Braun",
"homepage": "https://github.com/TheZwiss/backspace",
@@ -32,8 +32,9 @@
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"electron",
"electron-native-screenshare",
"esbuild",
"sharp"
],
"patchedDependencies": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+30 -7
View File
@@ -1,5 +1,5 @@
appId: com.backspace.desktop
productName: Backspace
productName: Resenhacord
artifactName: "${productName}-${version}-${arch}.${ext}"
directories:
output: dist-electron
@@ -14,16 +14,29 @@ files:
- "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**"
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
# below takes it out of the archive so the loader can find it.
asarUnpack:
- "**/*.node"
npmRebuild: false
afterPack: ./scripts/afterPack.js
publish:
- provider: github
owner: TheZwiss
repo: backspace
# Updates are served from this fork's own Gitea, not from GitHub.
#
# GitHub is only the build machine — it has the Windows runners the native
# audio module needs. Its repository is private, and electron-updater against
# a private GitHub repo would need a token shipped inside the app, which is a
# leaked token. Gitea serves release assets to anyone, so no credential ends
# up in the installer.
#
# The tag is fixed at `latest` on purpose: electron-updater fetches
# latest.yml before it knows which version exists, so the URL cannot contain
# a version. CI replaces that release's assets on every publish.
- provider: generic
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
protocols:
- name: Backspace
- name: Resenhacord
schemes:
- backspace
mac:
@@ -45,5 +58,15 @@ linux:
- deb
category: Network
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
# One-click, per-user install (%LOCALAPPDATA%), the way Discord ships.
#
# This is what makes an update silent: a per-user install needs no elevation,
# so electron-updater can run the installer with /S and no UAC prompt breaks
# the flow. The assisted installer we had before (oneClick: false) showed its
# wizard on every update and offered an all-users install, which would then
# demand elevation on each subsequent update.
#
# The trade is that a fresh install no longer asks where to put the app.
oneClick: true
perMachine: false
allowToChangeInstallationDirectory: false
+4 -3
View File
@@ -1,9 +1,9 @@
{
"name": "@backspace/desktop",
"version": "1.0.0",
"version": "2.1.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "Backspace",
"description": "Resenhacord",
"author": {
"name": "Jannis Braun",
"email": "backspace@backspace.chat"
@@ -18,9 +18,10 @@
"clean": "rm -rf dist dist-electron",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
"postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
},
"dependencies": {
"electron-native-screenshare": "^1.2.0",
"electron-updater": "^6.3.0",
"uiohook-napi": "^1.5.5"
},
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backspace — Connect</title>
<title>Resenhacord — Conectar</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
@@ -270,7 +270,7 @@
<div class="container">
<div class="card">
<div class="header">
<h1>Welcome to Backspace</h1>
<h1>Bem-vindo ao Resenhacord</h1>
<p>Connect to your instance</p>
</div>
@@ -360,7 +360,7 @@
// Update header copy to reflect the "switch" intent rather than the
// first-run "welcome" framing.
document.querySelector('.header h1').textContent = 'Switch instance';
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
document.querySelector('.header p').textContent = 'Conecte-se a outra instância, ou cancele para ficar nesta.';
}
}
init();
+4 -4
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Backspace — Recovery</title>
<title>Resenhacord — Recuperação</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
* { box-sizing: border-box; }
@@ -128,7 +128,7 @@
<div class="content">
<div class="container">
<div class="card">
<h1>Backspace couldn't load.</h1>
<h1>O Resenhacord não conseguiu abrir.</h1>
<p class="hint" id="hint">Loading…</p>
<div class="actions">
@@ -147,7 +147,7 @@
<pre id="diagnostic">loading…</pre>
</details>
<button class="action subtle" id="btn-quit">Quit Backspace</button>
<button class="action subtle" id="btn-quit">Fechar o Resenhacord</button>
</div>
</div>
</div>
@@ -270,7 +270,7 @@
async function init() {
if (!window.backspace) {
$('hint').textContent = 'Recovery bridge unavailable. Please quit and reinstall Backspace.';
$('hint').textContent = 'A ponte de recuperação não respondeu. Feche e reinstale o Resenhacord.';
return;
}
try {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 B

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 836 B

+204 -34
View File
@@ -36,28 +36,36 @@ import {
buildTrayMenuTemplate,
buildAppMenuTemplate,
type RecoveryState,
RELEASES_URL,
} from './recovery';
import { migrateUserData } from './userDataMigration';
// Override Electron's package.json-derived app name so userData lives at
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
// package name. Must run before any app.getPath('userData') consumer.
app.setName('Backspace');
app.setName('Resenhacord');
// One-time migration from the historical scoped path. After the move the old
// folder is gone, so subsequent launches hit the old-missing no-op branch.
// One-time migrations, oldest first, so an install that skipped a release
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
// step whose target already has data leaves it alone (`new-populated`), so a
// user who already ran the renamed build is never overwritten by stale files.
// Without the second step the rename would have logged everyone out: the
// instance URL and session live under userData.
{
const appDataDir = app.getPath('appData');
const oldParent = path.join(appDataDir, '@backspace');
const result = migrateUserData({
oldDir: path.join(oldParent, 'desktop'),
newDir: path.join(appDataDir, 'Backspace'),
oldParent,
});
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
const legacyParent = path.join(appDataDir, '@backspace');
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
];
for (const step of steps) {
const result = migrateUserData(step);
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
}
}
}
@@ -241,7 +249,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
enabled: openAtLogin,
path: process.execPath,
args: startMinimized ? ['--hidden'] : [],
name: 'Backspace',
name: 'Resenhacord',
});
} else {
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
@@ -265,6 +273,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
}
}
// ─── Native system-audio capture ────────────────────────────────────────────
//
// Electron's own `audio: 'loopback'` captures the whole output mix, which
// includes this app playing everyone else's voices — so those voices went back
// out inside the screen share and every listener heard themselves. Not acoustic
// echo: it is a digital copy of the output, so headphones never helped.
//
// This module captures with process-level isolation instead: only the shared
// window (include mode), or everything except this app (exclude mode).
interface NativeAudioMeta {
sampleRate: number;
channels: number;
bitsPerSample: number;
isFloat: boolean;
}
interface NativeScreenShareAudio {
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
stopCapture(): boolean;
getPidFromWindowHandle(windowHandle: number): number;
isAvailable(): boolean;
getLoadError(): string | null;
}
let nativeAudio: NativeScreenShareAudio | null = null;
try {
// Required lazily and defensively: a native module that fails to load must
// degrade to sharing without audio, never stop the app from starting.
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
if (!nativeAudio.isAvailable()) {
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
nativeAudio = null;
}
} catch (err) {
console.warn('[Main:ScreenShare] Native audio module missing:', err);
nativeAudio = null;
}
let nativeAudioActive = false;
/**
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
* Recovering the handle lets us capture only that window's audio, which is
* better than excluding ourselves: a game's sound goes out, the rest of the
* desktop does not.
*/
function windowHandleFromSourceId(sourceId: string): number | null {
const match = /^window:(\d+)/.exec(sourceId);
if (!match) return null;
const handle = Number(match[1]);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
function startNativeAudioCapture(sourceId: string): boolean {
if (!nativeAudio || nativeAudioActive) return false;
let targetPid = process.pid;
let includeMode = false;
const handle = windowHandleFromSourceId(sourceId);
if (handle !== null) {
const pid = nativeAudio.getPidFromWindowHandle(handle);
// pid 0 means the handle did not resolve; fall back to excluding ourselves
// rather than capturing nothing.
if (pid > 0) {
targetPid = pid;
includeMode = true;
}
}
try {
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('native-audio-data', data, meta);
});
nativeAudioActive = started;
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
return started;
} catch (err) {
console.error('[Main:ScreenShare] Native audio start failed:', err);
nativeAudioActive = false;
return false;
}
}
function stopNativeAudioCapture(): void {
if (!nativeAudio || !nativeAudioActive) return;
try {
nativeAudio.stopCapture();
} catch (err) {
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
}
nativeAudioActive = false;
}
// ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage {
@@ -341,7 +445,7 @@ function createWindow(): void {
: {}),
minWidth: 940,
minHeight: 500,
title: 'Backspace',
title: 'Resenhacord',
icon: path.join(__dirname, '..', 'build', 'icon.png'),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
...(process.platform !== 'darwin' ? {
@@ -474,7 +578,7 @@ function createTray(): void {
const icon = loadTrayIcon();
tray = new Tray(icon);
tray.setToolTip('Backspace');
tray.setToolTip('Resenhacord');
// Context menu is set by the recoveryStore subscriber in app.whenReady,
// which keeps the menu in sync with update/recovery state.
@@ -492,7 +596,9 @@ function createTray(): void {
function showNotification(title: string, body: string, onClick?: () => void): void {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body, silent: false });
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
// o app toca o seu próprio efeito — que combina com os demais sons dele.
const notification = new Notification({ title, body, silent: true });
notification.on('click', onClick ?? (() => {
mainWindow?.show();
mainWindow?.focus();
@@ -576,12 +682,25 @@ function registerIpcHandlers(): void {
ipcMain.on('install-update', () => {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall();
quitAndInstallSilently(autoUpdater);
} catch {
// Auto-updater not available
}
});
// The `update-downloaded` push is a one-shot: it reaches whatever page is
// loaded at that instant, and nothing else. It is lost when the window is in
// the tray (destroyed), when the check lands before the web app has mounted
// its listener, and on every reload. Since `downloaded` also latches off
// further polls, the banner could then never appear at all — which is why
// pressing "check for updates" by hand looked like the only thing that
// worked. The renderer now asks for the current state on mount instead of
// relying solely on catching the event live.
ipcMain.handle('get-update-status', () => {
const s = recoveryStore.get();
return { state: s.updateState, version: s.updateVersion };
});
ipcMain.on('check-for-updates', () => {
try {
const { autoUpdater } = require('electron-updater');
@@ -594,6 +713,10 @@ function registerIpcHandlers(): void {
ipcMain.handle('get-app-version', () => app.getVersion());
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('native-audio-stop', () => {
stopNativeAudioCapture();
});
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
// Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings
@@ -608,7 +731,7 @@ function registerIpcHandlers(): void {
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
// When the Run entry exists, its args are the source of truth for startMinimized.
// When the entry is absent (autostart is off), there is no OS state to read, so we
@@ -643,7 +766,7 @@ function registerIpcHandlers(): void {
if (process.platform === 'win32') {
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
@@ -702,6 +825,22 @@ function registerIpcHandlers(): void {
// ─── Auto-Update ────────────────────────────────────────────────────────────
/**
* Installs a staged update with no installer UI and relaunches the app.
*
* `quitAndInstall()` defaults to (isSilent=false, isForceRunAfter=false): on
* Windows that runs the NSIS installer with its full wizard, so "restart to
* update" meant clicking through a setup program. Silent mode passes /S and
* reuses the recorded install location; isForceRunAfter brings the app back up
* afterwards, which the wizard would otherwise leave to a checkbox.
*
* Both flags are inert on macOS and Linux, where the update is applied by
* swapping files — no installer runs either way.
*/
function quitAndInstallSilently(au: { quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void }): void {
au.quitAndInstall(true, true);
}
function initAutoUpdater(): void {
try {
const { autoUpdater } = require('electron-updater');
@@ -712,7 +851,14 @@ function initAutoUpdater(): void {
let updateConfirmed = false;
// Cadence state. `lastCheckAt` is stamped from the event rather than from
// the callers, so a manual "check for updates" from the settings panel
// counts too and a focus arriving right after it doesn't check twice.
let lastCheckAt = 0;
let downloaded = false;
autoUpdater.on('checking-for-update', () => {
lastCheckAt = Date.now();
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
updateConfirmed = false;
});
@@ -734,6 +880,7 @@ function initAutoUpdater(): void {
});
autoUpdater.on('update-downloaded', (info: { version: string }) => {
downloaded = true;
const version = info.version.slice(0, 32);
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
mainWindow?.webContents.send('update-downloaded', { version });
@@ -743,9 +890,9 @@ function initAutoUpdater(): void {
// (recovery mode) is visible — no need to also fire a native toast.
if (!mainWindow?.isFocused()) {
showNotification(
'Backspace update ready',
'Resenhacord update ready',
`Click to restart and install version ${version}.`,
() => autoUpdater.quitAndInstall(),
() => quitAndInstallSilently(autoUpdater),
);
}
});
@@ -768,22 +915,33 @@ function initAutoUpdater(): void {
if (updateConfirmed) {
mainWindow?.webContents.send('update-error', {
message,
releaseUrl: 'https://github.com/TheZwiss/backspace/releases/latest',
releaseUrl: RELEASES_URL,
});
}
});
// Initial check with 10s delay (existing behavior)
setTimeout(() => {
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
// A release published while the app sits open used to go unnoticed until
// the next poll — the startup check had long since run, and four hours is
// a long time to look at a stale version. Poll more often, and also check
// when the user comes back to the window, which is exactly when they would
// notice an update anyway.
const POLL_INTERVAL_MS = 30 * 60 * 1000;
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
// Periodic check every 4 hours (existing behavior)
setInterval(() => {
const maybeCheck = (): void => {
if (downloaded) return; // already staged; nothing left to look for
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 4 * 60 * 60 * 1000);
};
setTimeout(maybeCheck, 10_000);
setInterval(maybeCheck, POLL_INTERVAL_MS);
// App-level rather than mainWindow-level: the window is destroyed and
// recreated when closed to tray and reopened, and a listener bound to the
// old instance would go quiet after the first close.
app.on('browser-window-focus', maybeCheck);
} catch {
// Auto-updater unavailable — recovery surface still functions, just
// without update affordances. autoUpdaterRef stays null; recovery action
@@ -953,7 +1111,19 @@ if (!gotTheLock) {
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
// Fails on PipeWire-only systems without pulse compat — the
// renderer catches that and toasts the user.
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
// Audio no longer rides on the Electron stream: `loopback` would put
// this app's own output (everyone else's voices) back into the share.
// The native module captures it separately, isolated by process, and
// the renderer turns it into the track LiveKit publishes.
if (shareAudio) {
const started = startNativeAudioCapture(sourceId);
if (!started) {
// Tell the renderer so it can say the share is going out silently,
// instead of the user assuming sound is included.
mainWindow?.webContents.send('native-audio-unavailable');
}
}
callback({ video: selected });
} catch (err) {
console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing
@@ -971,7 +1141,7 @@ if (!gotTheLock) {
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
app.setAboutPanelOptions({
applicationName: 'Backspace',
applicationName: 'Resenhacord',
applicationVersion: app.getVersion(),
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
website: UPSTREAM_SOURCE_URL,
+19
View File
@@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('backspace', {
checkForUpdates: () => {
ipcRenderer.send('check-for-updates');
},
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
getVersion: () => ipcRenderer.invoke('get-app-version'),
// Window focus
@@ -65,6 +66,24 @@ contextBridge.exposeInMainWorld('backspace', {
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
},
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
// into a MediaStreamTrack for LiveKit.
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
// Copied out of the transferred buffer: reusing it across IPC messages
// would let a later chunk overwrite one still being read.
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
};
ipcRenderer.on('native-audio-data', listener);
return () => ipcRenderer.off('native-audio-data', listener);
},
onNativeAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on('native-audio-unavailable', listener);
return () => ipcRenderer.off('native-audio-unavailable', listener);
},
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
},
+1 -1
View File
@@ -166,7 +166,7 @@ describe('buildTrayMenuTemplate', () => {
it('includes Show/Hide/Change Instance/Quit base items', () => {
const items = buildTrayMenuTemplate(defaultState());
const labels = items.map((i) => i.label);
expect(labels).toContain('Show Backspace');
expect(labels).toContain('Mostrar o Resenhacord');
expect(labels).toContain('Hide');
expect(labels).toContain('Change Instance');
expect(labels).toContain('Quit');
+13 -3
View File
@@ -17,6 +17,15 @@ export type UpdateState =
| 'downloaded'
| 'error';
/**
* Where a manual download lands when auto-update fails.
*
* The upstream GitHub repository this used to point at is private for this
* fork, so the link 404'd for everyone who followed it. Releases are on the
* fork's own Gitea, which is also where the updater feed lives.
*/
export const RELEASES_URL = 'https://git.resenha.website/devsyncwrld/backspace/releases';
export interface RecoveryState {
mode: 'normal' | 'recovery';
reason: { code: RecoveryReasonCode; detail: string } | null;
@@ -122,7 +131,7 @@ export function buildTrayMenuTemplate(
actions?: Partial<MenuActions>,
): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [
{ label: 'Show Backspace', click: actions?.onShow },
{ label: 'Mostrar o Resenhacord', click: actions?.onShow },
{ label: 'Hide', click: actions?.onHide },
{ type: 'separator' },
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
@@ -419,7 +428,8 @@ export function handleRecoveryAction(action: RecoveryAction): void {
// Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit.
// Force-kill-fix: if user reaches recovery and clicks here, install
// happens cleanly even if the on-quit hook would otherwise be bypassed.
autoUpdaterRef?.quitAndInstall();
// Silent + relaunch, matching the in-app banner: no installer wizard.
autoUpdaterRef?.quitAndInstall(true, true);
return;
}
case 'change-instance': {
@@ -440,7 +450,7 @@ export function handleRecoveryAction(action: RecoveryAction): void {
return;
}
case 'open-releases': {
shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest');
shell.openExternal(RELEASES_URL);
return;
}
case 'quit': {
+7
View File
@@ -0,0 +1,7 @@
node_modules/
# Gerados por `cap sync` a cada build — versioná-los só criaria diffs ruidosos
# e o risco de o APK sair com uma cópia velha.
android/app/src/main/assets/public/
android/app/src/main/assets/capacitor.config.json
android/app/src/main/assets/capacitor.plugins.json
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+57
View File
@@ -0,0 +1,57 @@
apply plugin: 'com.android.application'
android {
namespace "com.resenhacord.app"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.resenhacord.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Mantidos em sincronia com a versao do monorepo. O versionCode e o
// numero que o Android usa para decidir se um APK e mais novo, e ele
// precisa ser inteiro e sempre crescente: 2.1.0 -> 20100.
versionCode 20100
versionName "2.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
O tipo "microphone" tem de bater com o que o servico passa para o
startForeground e com a permissao FOREGROUND_SERVICE_MICROPHONE. A
partir do Android 14 os tres precisam concordar, senao o sistema
derruba o app na hora de entrar na call.
-->
<service
android:name=".VoiceForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Voz e video. A WebView nao pede nada ao sistema sozinha: quando a pagina
chama getUserMedia, o bridge do Capacitor intercepta o pedido e traduz
para estas permissoes nativas. Sem declara-las aqui, a chamada falha com
NotAllowedError e o microfone simplesmente nao liga.
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Trocar entre alto-falante e fone durante a chamada. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Android 13+ nao mostra notificacao nenhuma sem esta permissao. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Chamada com a tela apagada. O servico em primeiro plano impede o sistema
de congelar o processo; o wake lock impede a CPU de dormir. Sem os dois, a
WebView para de rodar o JavaScript e a call cai.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--
O aparelho precisa ter microfone; camera e opcional para nao excluir
tablets sem camera frontal da lista de aparelhos compativeis.
-->
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -0,0 +1,36 @@
package com.resenhacord.app;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final int REQ_NOTIFICATIONS = 100;
@Override
public void onCreate(Bundle savedInstanceState) {
// Plugins que moram no proprio app nao sao descobertos sozinhos, e o
// registro tem de acontecer antes do super.onCreate — depois disso a
// ponte ja foi montada e o plugin nao aparece para o JavaScript.
registerPlugin(VoicePlugin.class);
super.onCreate(savedInstanceState);
requestNotificationPermission();
}
/**
* A partir do Android 13 a notificacao da chamada nao aparece sem esta
* permissao. O servico continua rodando de qualquer forma — a chamada nao
* cai se o usuario recusar —, mas ele fica sem nada visivel na barra, o que
* so parece defeito. Por isso perguntamos, e seguimos com a recusa.
*/
private void requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) return;
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQ_NOTIFICATIONS);
}
}
@@ -0,0 +1,151 @@
package com.resenhacord.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
/**
* Mantem o app vivo enquanto ha uma chamada em andamento.
*
* Sem isto, trancar a tela derruba a call. O Android congela processos em
* segundo plano — e uma WebView congelada para de rodar o JavaScript que
* mantem a conexao do LiveKit de pe. Um servico em primeiro plano e a unica
* coisa que o sistema aceita como "este app precisa continuar rodando", e ele
* exige a notificacao permanente que aparece junto: e o preco combinado, o
* usuario sempre ve que algo esta ativo.
*
* O wake lock parcial e separado e igualmente necessario: o servico impede o
* congelamento do processo, mas nao impede a CPU de dormir.
*/
public class VoiceForegroundService extends Service {
public static final String ACTION_START = "com.resenhacord.app.VOICE_START";
public static final String ACTION_STOP = "com.resenhacord.app.VOICE_STOP";
public static final String ACTION_HANGUP = "com.resenhacord.app.VOICE_HANGUP";
public static final String EXTRA_CHANNEL = "channel";
private static final String CHANNEL_ID = "voice_call";
private static final int NOTIFICATION_ID = 1;
private PowerManager.WakeLock wakeLock;
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (ACTION_STOP.equals(action)) {
stop();
return START_NOT_STICKY;
}
if (ACTION_HANGUP.equals(action)) {
// O botao da notificacao nao desliga a chamada sozinho: quem sabe
// como sair da sala e o app web. Avisamos o JavaScript e deixamos
// ele encerrar; o servico para quando o JS confirmar a saida.
VoicePlugin.emitHangup();
return START_NOT_STICKY;
}
String channel = intent != null ? intent.getStringExtra(EXTRA_CHANNEL) : null;
startForegroundWithNotification(channel);
acquireWakeLock();
return START_STICKY;
}
private void startForegroundWithNotification(String channelName) {
createNotificationChannel();
Intent open = new Intent(this, MainActivity.class);
open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent openPending = PendingIntent.getActivity(
this, 0, open, PendingIntent.FLAG_IMMUTABLE);
Intent hangup = new Intent(this, VoiceForegroundService.class);
hangup.setAction(ACTION_HANGUP);
PendingIntent hangupPending = PendingIntent.getService(
this, 1, hangup, PendingIntent.FLAG_IMMUTABLE);
String text = channelName != null && !channelName.isEmpty()
? channelName
: getString(R.string.voice_notification_text);
Notification notification = new Notification.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.voice_notification_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_stat_voice)
.setContentIntent(openPending)
.addAction(new Notification.Action.Builder(
null, getString(R.string.voice_notification_hangup), hangupPending).build())
.setOngoing(true)
.setUsesChronometer(true)
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// O tipo e obrigatorio a partir do Android 14: sem ele o sistema
// recusa o servico e derruba o app com uma excecao.
startForeground(NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
} else {
startForeground(NOTIFICATION_ID, notification);
}
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager.getNotificationChannel(CHANNEL_ID) != null) return;
// Importancia baixa de proposito: a notificacao existe porque o sistema
// exige, nao para chamar atencao. Som ou vibracao aqui tocariam a cada
// entrada em call, por cima da propria chamada.
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
getString(R.string.voice_channel_name),
NotificationManager.IMPORTANCE_LOW);
channel.setDescription(getString(R.string.voice_channel_description));
channel.setShowBadge(false);
manager.createNotificationChannel(channel);
}
private void acquireWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) return;
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Resenhacord::voice");
// Sem timeout infinito: se o app morrer de um jeito que nao passe pelo
// stop(), o sistema solta o lock sozinho depois de duas horas em vez de
// segurar a CPU para sempre.
wakeLock.acquire(2 * 60 * 60 * 1000L);
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
private void stop() {
releaseWakeLock();
stopForeground(true);
stopSelf();
}
@Override
public void onDestroy() {
releaseWakeLock();
super.onDestroy();
}
}
@@ -0,0 +1,66 @@
package com.resenhacord.app;
import android.content.Intent;
import android.os.Build;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.lang.ref.WeakReference;
/**
* Ponte entre o app web e o servico de chamada.
*
* O app web e quem sabe quando ha uma call: ele fala com o LiveKit. O lado
* nativo e quem sabe pedir ao Android para nao congelar o processo. Este plugin
* so liga as duas pontas.
*/
@CapacitorPlugin(name = "VoiceForeground")
public class VoicePlugin extends Plugin {
/**
* Referencia fraca de proposito: o servico precisa avisar o JavaScript
* quando alguem toca em "Sair" na notificacao, mas ele nao pode ser o
* motivo de a instancia do plugin (e a Activity presa a ela) continuar
* viva depois de destruida.
*/
private static WeakReference<VoicePlugin> instance = new WeakReference<>(null);
@Override
public void load() {
instance = new WeakReference<>(this);
}
@PluginMethod
public void start(PluginCall call) {
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
intent.setAction(VoiceForegroundService.ACTION_START);
intent.putExtra(VoiceForegroundService.EXTRA_CHANNEL, call.getString("channel"));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getContext().startForegroundService(intent);
} else {
getContext().startService(intent);
}
call.resolve();
}
@PluginMethod
public void stop(PluginCall call) {
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
intent.setAction(VoiceForegroundService.ACTION_STOP);
getContext().startService(intent);
call.resolve();
}
/** Chamado pelo servico quando o botao "Sair" da notificacao e tocado. */
static void emitHangup() {
VoicePlugin plugin = instance.get();
if (plugin != null) {
plugin.notifyListeners("hangup", new JSObject());
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Icone pequeno da notificacao. O Android descarta a cor e usa so o formato
(a partir do Lollipop ele e pintado de branco sobre a silhueta), entao aqui
vale desenho solido e simples — um microfone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Tela de abertura como camadas em vez das splash.png que o Capacitor gera para
cada densidade e orientacao. Duas razoes: a cor de fundo fica exata em
qualquer aparelho (as PNGs padrao sao claras e piscavam branco antes da
WebView pintar), e a marca escala sozinha sem manter doze arquivos.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/brand_background" />
<item
android:drawable="@drawable/brand_mark"
android:gravity="center"
android:width="128dp"
android:height="128dp" />
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- theme_color / background_color do manifest do PWA, para a abertura
nativa e a WebView comecarem na mesma cor. -->
<color name="brand_background">#0B0B10</color>
<color name="colorPrimary">#0B0B10</color>
<color name="colorPrimaryDark">#0B0B10</color>
<color name="colorAccent">#A78BFA</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- A mesma base escura da marca; o icone adaptativo recorta o primeiro
plano sobre ela em qualquer formato que o launcher use. -->
<color name="ic_launcher_background">#0B0B10</color>
</resources>
@@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Resenhacord</string>
<string name="title_activity_main">Resenhacord</string>
<string name="package_name">com.resenhacord.app</string>
<string name="custom_url_scheme">com.resenhacord.app</string>
<string name="voice_channel_name">Chamada em andamento</string>
<string name="voice_channel_description">Mantem a chamada de voz ativa com o app em segundo plano</string>
<string name="voice_notification_title">Resenhacord</string>
<string name="voice_notification_text">Em call</string>
<string name="voice_notification_hangup">Sair</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
classpath 'com.google.gms:google-services:4.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.12.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
}
+41
View File
@@ -0,0 +1,41 @@
import type { CapacitorConfig } from '@capacitor/cli';
/**
* O APK e uma casca fina: a WebView carrega a instancia pela rede em vez de
* servir uma copia do app web embutida no pacote.
*
* Duas razoes, nesta ordem:
*
* 1. Atualizacao. O site muda a cada deploy. Com os arquivos embutidos, cada
* mudanca no app web exigiria republicar e reinstalar o APK a mao — nao ha
* updater automatico no Android como o electron-updater faz no desktop.
* Pela rede, o telefone recebe a mesma versao que o navegador, na hora.
*
* 2. Origem. O cliente fala com /api e /ws na mesma origem. Embutido, a pagina
* rodaria em https://localhost e toda chamada viraria cross-origin, o que
* obrigaria a abrir CORS no servidor para uma origem que qualquer app pode
* reivindicar.
*
* O preco e nao funcionar offline, o que para um app de chat em tempo real nao
* e perda: sem servidor nao ha o que mostrar. A pagina em public/ so aparece se
* a WebView nao alcancar o servidor.
*/
const config: CapacitorConfig = {
appId: 'com.resenhacord.app',
appName: 'Resenhacord',
webDir: 'public',
android: {
// A captura de audio e video da WebView e concedida pelo bridge do
// Capacitor, que traduz o pedido do getUserMedia para a permissao nativa.
allowMixedContent: false,
},
server: {
url: 'https://chat.resenha.website',
// Sem cleartext: a instancia e servida por HTTPS (Caddy), e o WebRTC do
// LiveKit exige contexto seguro de qualquer forma.
cleartext: false,
androidScheme: 'https',
},
};
export default config;
+1181
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@backspace/mobile",
"version": "2.1.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "Resenhacord para Android",
"scripts": {
"sync": "cap sync android",
"open": "cap open android",
"build:debug": "cd android && ./gradlew assembleDebug",
"build:release": "cd android && ./gradlew assembleRelease"
},
"dependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/core": "^7.0.0"
},
"devDependencies": {
"@capacitor/cli": "^7.0.0",
"typescript": "^5.7.2"
}
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Resenhacord</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; min-height: 100dvh;
display: grid; place-items: center; padding: 24px;
background: #0b0b10; color: #e7e7ee;
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
text-align: center;
}
h1 { font-size: 19px; margin: 0 0 8px; }
p { margin: 0; color: #9a9aab; max-width: 30ch; }
</style>
<h1>Sem conexão</h1>
<p>Não deu pra falar com o servidor. Confere a internet e abre de novo.</p>
@@ -0,0 +1,4 @@
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
@@ -0,0 +1,26 @@
CREATE TABLE `space_emojis` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `space_stickers` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
@@ -0,0 +1 @@
ALTER TABLE `messages` ADD `type` text DEFAULT 'user' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `users` ADD `profile_style` text;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More