4 Commits

Author SHA1 Message Date
bd2b8e6241 Fix Android IME typing: editor clobbered its own autosave echo
Some checks failed
ci / test (push) Has been cancelled
ci / release (push) Has been cancelled
The $: block applying note changes to CodeMirror listed the autosave
bookkeeping vars (saveTimer, lastKnown) as dependencies, so the 400 ms
autosave re-ran it before App delivered the updated note prop. That stale
run misread the save echo as an external sync change and replaced the
whole doc mid-typing: on Android this aborted every IME composition and
threw the cursor to the top of the note.

Apply note/query via $: fn(prop) so the statements re-run only when the
prop itself changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:04:24 +02:00
4328bbaeda Install git in the Go build stage for the GOPROXY direct fallback
Some checks failed
ci / test (push) Has been cancelled
ci / release (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:50:04 +02:00
bf8e98dd49 Make GOPROXY a build arg (proxy.golang.org is blocked on some networks)
Some checks failed
ci / test (push) Has been cancelled
ci / release (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:39:25 +02:00
2ebfa4caaf Add container deployment: Dockerfile, compose, systemd unit, redeploy script
Some checks failed
ci / test (push) Has been cancelled
ci / release (push) Has been cancelled
Multi-stage build (node -> go -> alpine) producing the static tefterd
binary with the frontend embedded. Compose follows the house style for
the podman/SELinux server (:Z volume label + label:disable). deploy/
holds the user systemd unit and redeploy-tefter.sh, mirroring the
apelacija setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:26:01 +02:00
7 changed files with 149 additions and 12 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitea
build
desktop
frontend/node_modules
frontend/dist
server/webdist/dist
notes.zip
*.md
.env.docker

1
.gitignore vendored
View File

@@ -9,4 +9,5 @@ desktop/build/bin/
*.db-wal *.db-wal
*.db-shm *.db-shm
notes.zip notes.zip
.env.docker
!server/webdist/dist/.gitkeep !server/webdist/dist/.gitkeep

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
# Build the frontend bundle (vite), then the static tefterd binary that
# embeds it, then ship binary-only. VERSION flows in from compose
# (${TEFTER_VERSION}) so `tefterd version` matches the git describe.
# Fully-qualified images (podman enforces short-name resolution non-interactively).
FROM docker.io/library/node:22-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
ARG VERSION=dev
RUN TEFTER_VERSION=$VERSION npx vite build
FROM docker.io/library/golang:1.25-alpine AS build
# git enables the GOPROXY "direct" fallback (fetch modules from their VCS).
RUN apk add --no-cache git
# Overridable for hosts where proxy.golang.org (Google) is unreachable —
# set e.g. GOPROXY=https://goproxy.io,direct in .env.docker. Module
# integrity is still verified against the committed go.sum either way.
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=$GOPROXY
WORKDIR /src/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
# go:embed cannot reach outside the module dir, so the bundle is copied in
# (same as the Makefile does).
COPY --from=frontend /src/frontend/dist ./webdist/dist
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=$VERSION" -o /tefterd .
FROM docker.io/library/alpine:3.22
RUN adduser -D -H tefter && mkdir -p /data && chown tefter /data
COPY --from=build /tefterd /usr/local/bin/tefterd
USER tefter
ENV TEFTER_DB=/data/tefter.db
# The auth token is generated and printed to the logs on first start.
EXPOSE 8420
VOLUME /data
CMD ["tefterd"]

32
deploy/redeploy-tefter.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -e
APP_DIR=$HOME/tefter
SERVICE=tefter.service
cd "$APP_DIR"
# Host-specific settings (WEB_PORT, GOPROXY, ...) live in .env.docker next to
# the compose file; export them so compose interpolation sees them.
set -a; [ -f .env.docker ] && . ./.env.docker; set +a
WEB_PORT=${WEB_PORT:-8420}
echo "==> Pulling latest code..."
git pull
echo "==> Rebuilding container..."
TEFTER_VERSION=$(git describe --tags --always --dirty) podman-compose build
echo "==> Restarting service..."
systemctl --user restart $SERVICE
echo "==> Waiting for boot..."
sleep 5
if curl -s -o /dev/null -w "" http://localhost:$WEB_PORT/; then
echo "==> Deploy successful! App is running on port $WEB_PORT."
podman-compose logs --tail 5 tefter 2>&1
else
echo "==> WARNING: App may not be ready yet. Check logs:"
podman-compose logs --tail 20 tefter 2>&1
fi

20
deploy/tefter.service Normal file
View File

@@ -0,0 +1,20 @@
# User unit — install to ~/.config/systemd/user/tefter.service, then:
# systemctl --user daemon-reload && systemctl --user enable --now tefter.service
# Requires lingering (loginctl enable-linger) so it survives logout.
[Unit]
Description=Tefter notes server (podman-compose)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/tefter
EnvironmentFile=%h/tefter/.env.docker
ExecStart=/home/hamo/.local/bin/podman-compose up
ExecStop=/home/hamo/.local/bin/podman-compose down
Restart=always
RestartSec=10
[Install]
WantedBy=default.target

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
tefter:
build:
context: .
args:
VERSION: ${TEFTER_VERSION:-dev}
GOPROXY: ${GOPROXY:-https://proxy.golang.org,direct}
environment:
TEFTER_DB: /data/tefter.db
volumes:
# ":Z" relabels for SELinux (required on the podman/SELinux server; a
# harmless no-op on non-SELinux Docker hosts).
- data:/data:Z
security_opt:
- label:disable
ports:
- "${WEB_PORT:-8420}:8420"
restart: unless-stopped
volumes:
data:

View File

@@ -106,39 +106,52 @@
// Swap document when a different note is opened; apply external (sync) changes // Swap document when a different note is opened; apply external (sync) changes
// without clobbering in-flight typing. // without clobbering in-flight typing.
$: if (view && note) { function applyNote(n: Note) {
if (note.id !== currentId) { if (!view || !n) return;
if (n.id !== currentId) {
if (saveTimer) { if (saveTimer) {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = null; saveTimer = null;
flushSave(currentId, view.state.doc.toString()); flushSave(currentId, view.state.doc.toString());
} }
currentId = note.id; currentId = n.id;
lastKnown = note.content; lastKnown = n.content;
view.setState(makeState(note.content)); view.setState(makeState(n.content));
} else if (note.content !== lastKnown) { } else if (n.content !== lastKnown) {
// Store changed underneath us (sync pull / conflict replacement) — not an // Store changed underneath us (sync pull / conflict replacement) — not an
// echo of our own autosave. Replace the doc. // echo of our own autosave. Replace the doc.
lastKnown = note.content; lastKnown = n.content;
if (saveTimer) { if (saveTimer) {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = null; saveTimer = null;
} }
if (note.content !== view.state.doc.toString()) { if (n.content !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: note.content } }); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: n.content } });
} }
} }
} }
let lastQuery = ''; let lastQuery = '';
$: if (view && query !== lastQuery) { function applyQuery(q: string) {
lastQuery = query; if (!view || q === lastQuery) return;
view.dispatch({ effects: searchMark.reconfigure(searchExtension(query)) }); lastQuery = q;
view.dispatch({ effects: searchMark.reconfigure(searchExtension(q)) });
} }
// Deliberately `$: fn(prop)` instead of inlining the logic in a reactive
// block: these must re-run ONLY when the prop itself changes. Svelte tracks
// every variable a `$:` statement mentions (including writes like
// `saveTimer = null` deep in the autosave path), and a re-run triggered by
// our own bookkeeping sees a stale `note`, misreads the autosave echo as an
// external sync change, and replaces the doc mid-typing — which on Android
// aborts IME composition and throws the cursor to the top of the note.
$: applyNote(note);
$: applyQuery(query);
onMount(() => { onMount(() => {
currentId = note.id; currentId = note.id;
lastKnown = note.content; lastKnown = note.content;
lastQuery = query; // makeState below bakes in the current query
view = new EditorView({ state: makeState(note.content), parent: host }); view = new EditorView({ state: makeState(note.content), parent: host });
}); });