GSoC 2026 Proposal Draft – Idea 9: LAN Sync – Ahmed Idani
Links
- Project idea: Idea #9: LAN Sync
- GitHub: Ahmed-Idani
- Forum introduction: Introducing Idani_Ahmed
- Pull requests submitted to Joplin (all merged):
- Other experience: Part-time software engineer at Bakchich Ads (Next.js + Strapi + PayPal), real-time voting system (Express + MongoDB, 500+ concurrent users), infrastructure engineering at Open Organic Robotics (Azure AKS, now in production)
1. Introduction
I'm Ahmed Idani, a 4th-year engineering student at INSAT (National Institute of Applied Science and Technologies) in Tunis, studying Computer Networks and Telecommunications. Networking protocols are something I study in depth as part of my degree, not a side interest. I hold a CCNA certification, and my coursework covers TCP/IP, distributed systems, network security, and cloud computing.
I've been contributing to open source through Joplin since late 2025. I started with a locale handling fix (#13994), then fixed an Electron relaunch bug on Linux (#14530), and most recently fixed a sync crash affecting users who switch from Joplin Server to WebDAV (#14649). That last one required me to understand how sync targets are registered, how ShareService interacts with the Synchronizer, and how SyncTargetRegistry decides what each target supports. That's exactly the area this project extends.
Outside of Joplin, I've worked on infrastructure and networking professionally. At Sotetel I assisted with firewall migrations (Cisco to Fortinet) and Layer 2 networking. At Open Organic Robotics I was part of the v1 team as an intern, where I engineered multi-tenant isolation on Azure AKS with per-user namespaces, Calico network policies, and RBAC for 200+ concurrent users. The platform is now in production and was tested by engineers from Google and Meta at Deep Learning Indaba 2025. That experience with network-level isolation is directly relevant to designing secure peer communication on a shared LAN. I also work part-time as a software engineer at Bakchich, where I shipped an ads platform with PayPal and Flouci payment integrations using Next.js and Strapi.
2. Project Summary
Problem: Every sync option in Joplin needs either the internet or a manually configured server. There's no way to sync two devices directly over a local network without external tools. The File System sync target works over a network share, but it requires manual setup, has no device discovery, and doesn't work on mobile.
What I'll implement: A new sync target called "LAN Sync" that lets two Joplin instances on the same Wi-Fi discover each other automatically, pair with a PIN, and sync directly. No cloud, no accounts, no external software. One device acts as a lightweight HTTPS host, the other syncs against it using the existing Synchronizer.
Expected outcome: A user opens Joplin on their desktop, enables "LAN Sync" as host. They open Joplin on their Android phone, it discovers the desktop, they confirm a PIN, and their notes sync. After sync, both devices have identical data and work independently offline.
Out of scope: Multi-device mesh sync (a second guest could technically sync to the same host, but testing and validating conflict behavior with 3+ devices is a separate effort, so I'm keeping it to two for v1). iOS hosting (no JVM on iOS for NanoHttpd; v1 supports iOS as guest using cross-platform React Native code for FileApiDriverLAN and react-native-zeroconf). Custom encryption (Joplin's E2EE encrypts items at the BaseItem serialization layer before they reach any FileApi driver, so LAN Sync gets encryption for free). Full PKI/CA infrastructure and formal security audit (post-GSoC hardening tasks; v1 uses self-signed TLS with fingerprint pinning after pairing).
3. Technical Approach
Architecture
I'm using a host-guest model. One device runs a lightweight HTTP server (LanSyncServer) that wraps FileApiDriverLocal — the same driver the File System sync target uses. The other device discovers it via mDNS and syncs against it using a new FileApiDriverLAN that speaks HTTP.
I went with this instead of true P2P because it maps directly onto Joplin's existing sync architecture. The Synchronizer already knows how to talk to a FileApi driver. I just write a driver that talks HTTP instead of touching the filesystem. No changes needed to the sync algorithm, conflict resolution, or delta detection.
The host doesn't run a Synchronizer itself. It's a passive file store, similar to how Dropbox or a WebDAV server just holds files without initiating sync. The guest's Synchronizer handles everything: it pushes local changes to the host (PUT), deletes remote items the guest removed (DELETE), then pulls any changes from the host via delta (GET). After a sync cycle, both devices have the same data and are fully independent.
Why this isn't just "run a WebDAV server on your LAN":
- Zero config — mDNS handles discovery, no URLs to type in
- No extra software — built into Joplin itself
- Works on Android — the phone can discover and sync natively
- Simple pairing — a 6-digit PIN, not username/password management
- Encrypted by default — self-signed TLS with certificate pinning after pairing
Components
1. Peer Discovery (LanDiscoveryService) — DNS-SD (RFC 6763) over mDNS for zero-config discovery. Service type: _joplin-sync._tcp.local. (same protocol used by AirPrint, Chromecast, Spotify Connect). On desktop: bonjour-service npm package (TypeScript, 11M+ weekly downloads, actively maintained, provides DNS-SD semantics on top of multicast-dns). On Android: react-native-zeroconf wrapping NsdManager, with its DNSSD backend as a more reliable alternative on devices with buggy NSD implementations. On iOS: same react-native-zeroconf package wrapping Apple's Bonjour framework. The discovery list displays each peer as displayName (...last4ofDeviceId) — e.g., "Ahmed's Desktop (...a3f7)" — so the user can cross-check against the host's screen to guard against rogue host impersonation on shared networks.
import Bonjour, { Service } from "bonjour-service";
interface LanPeer {
deviceId: string;
displayName: string;
host: string;
port: number;
protocolVersion: number;
}
class LanDiscoveryService {
private bonjour_: Bonjour = null;
private service_: any = null;
private browser_: any = null;
private peers_: Map<string, LanPeer> = new Map();
private onPeersChanged_: () => void = null;
// Host advertises itself on the LAN. bonjour-service handles
public async advertise(deviceId: string, port: number, displayName: string) {
// Destroy any existing instance before creating a new one to avoid leaking
// the old multicast-dns socket and its event listeners.
if (this.bonjour_) {
this.bonjour_.destroy();
}
this.bonjour_ = new Bonjour();
this.service_ = this.bonjour_.publish({
name: displayName,
type: "joplin-sync",
port: port,
txt: { deviceId, name: displayName, v: "1" },
});
}
public async startDiscovery() {
if (this.bonjour_) {
this.bonjour_.destroy();
}
this.bonjour_ = new Bonjour();
this.browser_ = this.bonjour_.find({ type: "joplin-sync" });
this.browser_.on("up", (service: Service) => {
const txt = service.txt as Record<string, string>;
if (!txt.deviceId) return;
this.peers_.set(txt.deviceId, {
deviceId: txt.deviceId,
displayName: txt.name || service.name,
host: service.host,
port: service.port,
protocolVersion: parseInt(txt.v, 10) || 1,
});
if (this.onPeersChanged_) this.onPeersChanged_();
});
this.browser_.on("down", (service: Service) => {
const txt = service.txt as Record<string, string>;
if (txt.deviceId) {
this.peers_.delete(txt.deviceId);
if (this.onPeersChanged_) this.onPeersChanged_();
}
});
}
public peers(): LanPeer[] {
return Array.from(this.peers_.values());
}
public async stop() {
if (this.service_) this.service_.stop();
if (this.browser_) this.browser_.stop();
if (this.bonjour_) this.bonjour_.destroy();
}
}
2. Trust & Pairing — One-time PIN exchange. Host generates a 6-digit PIN (1M combinations vs 10K for 4-digit), displays it on screen for 2 minutes before it expires and rotates. Guest sends the PIN; host returns a 256-bit auth token stored in sync.12.authToken (secure). All subsequent requests use Bearer token auth.
The host applies exponential backoff on failed PIN attempts (1s after 3 failures, 30s after 8) and locks pairing for 5 minutes after 10 failures — following the same rate-limiter-flexible pattern Joplin Server uses for login brute-force protection (limiterLoginBruteForce.ts). Before completing pairing, the host shows a confirmation dialog with the guest's device name so the user can verify they're pairing with the right device.
The host maintains a paired devices list (device name, deviceId, paired date, last sync time) persisted alongside sync data. Users can revoke any device from the settings UI, which removes its token immediately.
3. LAN Sync HTTP Server (LanSyncServer) — Lightweight HTTPS server on the host wrapping FileApiDriverLocal. Uses a self-signed TLS certificate generated at first launch using the selfsigned npm package (the same approach used by webpack-dev-server), so all traffic between host and guest is encrypted. Node's built-in crypto module can parse certificates (X509Certificate) but cannot create them, so selfsigned handles key pair generation and certificate signing. The guest pins the host's certificate fingerprint after pairing, rejecting any cert mismatch on subsequent connections. Maps 1:1 to the driver interface:
| Endpoint | Maps to | What it does |
|---|---|---|
GET /api/stat/:path |
stat() |
File metadata |
GET /api/list/:path |
list() |
Directory listing (PaginatedList) |
GET /api/liststat/:path |
readDirStats() |
Flat ItemStat[] for basicDelta |
GET /api/get/:path |
get() |
Download content |
PUT /api/put/:path |
put() |
Upload content |
DELETE /api/delete/:path |
delete() |
Remove item |
POST /api/mkdir/:path |
mkdir() |
Create directory |
GET /api/info |
— | Server version & capabilities |
import { createServer as createHttpsServer, ServerOptions } from "https";
import { IncomingMessage, ServerResponse } from "http";
import { resolve as pathResolve, sep as pathSep } from "path";
import * as selfsigned from "selfsigned";
import FileApiDriverLocal from "./file-api-driver-local";
class LanSyncServer {
private server_: any = null;
private driver_: FileApiDriverLocal;
private basePath_: string;
private port_: number;
private authTokens_: Set<string>;
private tlsKey_: string;
private tlsCert_: string;
public constructor(
basePath: string,
port: number,
authTokens: Set<string>,
tlsKey: string,
tlsCert: string,
) {
this.basePath_ = pathResolve(basePath);
this.port_ = port;
this.authTokens_ = authTokens;
this.tlsKey_ = tlsKey;
this.tlsCert_ = tlsCert;
this.driver_ = new FileApiDriverLocal();
}
// Generates a self-signed TLS certificate at first launch.
// Call once and persist key + cert in secure settings (sync.12.tlsKey, sync.12.tlsCert).
// selfsigned is used because Node's built-in crypto can parse certs (X509Certificate)
// but has no API for creating them.
public static generateTlsCredentials(): { key: string; cert: string } {
const attrs = [{ name: "commonName", value: "joplin-lan-sync" }];
const pems = selfsigned.generate(attrs, {
keySize: 2048,
days: 3650, // 10 years — avoids forcing users to re-pair
algorithm: "sha256",
});
return { key: pems.private, cert: pems.cert };
}
public async start() {
const tlsOptions: ServerOptions = {
key: this.tlsKey_,
cert: this.tlsCert_,
};
this.server_ = createHttpsServer(tlsOptions, (req, res) =>
this.handleRequest_(req, res),
);
this.server_.listen(this.port_, "0.0.0.0");
}
private sanitizePath_(itemPath: string): string {
if (itemPath.includes("\0")) throw new Error("Invalid path");
const resolved = pathResolve(this.basePath_, itemPath);
const base = this.basePath_.endsWith(pathSep)
? this.basePath_
: this.basePath_ + pathSep;
if (resolved !== this.basePath_ && !resolved.startsWith(base)) {
throw new Error("Path traversal detected");
}
return resolved;
}
private async handleRequest_(req: IncomingMessage, res: ServerResponse) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token || !this.authTokens_.has(token)) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
const url = new URL(req.url, `https://${req.headers.host}`);
const itemPath = decodeURIComponent(
url.pathname.replace(/^\/api\/\w+\//, ""),
);
try {
const fullPath = this.sanitizePath_(itemPath);
if (req.method === "GET" && url.pathname.startsWith("/api/stat/")) {
const stat = await this.driver_.stat(fullPath);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(stat));
} else if (
req.method === "GET" &&
url.pathname.startsWith("/api/list/")
) {
const items = await this.driver_.list(fullPath);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(items));
} else if (
req.method === "GET" &&
url.pathname.startsWith("/api/liststat/")
) {
const stats = await this.driver_.fsDriver().readDirStats(fullPath);
const items = stats.map((s: any) => ({
path: s.path,
updated_time: s.mtime.getTime(),
isDir: s.isDirectory(),
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(items));
} else if (req.method === "GET" && url.pathname.startsWith("/api/get/")) {
const content = await this.driver_.get(fullPath, {});
res.writeHead(200, { "Content-Type": "application/octet-stream" });
res.end(content);
} else if (req.method === "PUT" && url.pathname.startsWith("/api/put/")) {
const body = await this.readBody_(req);
await this.driver_.put(fullPath, body, {});
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} else if (
req.method === "DELETE" &&
url.pathname.startsWith("/api/delete/")
) {
await this.driver_.delete(fullPath);
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} else if (
req.method === "POST" &&
url.pathname.startsWith("/api/mkdir/")
) {
await this.driver_.mkdir(fullPath);
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
}
} catch (error) {
res.writeHead(500);
res.end(JSON.stringify({ error: error.message }));
}
}
private readBody_(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
public async stop() {
if (this.server_) this.server_.close();
}
}
4. FileApi Driver (FileApiDriverLAN) — Guest-side HTTP client implementing the standard driver interface. One subtlety I found reading the code: list() returns a PaginatedList ({items, hasMore, context}) but basicDelta() expects its getDirStatFn callback to return a flat ItemStat[] that it can .sort() on. FileApiDriverLocal handles this by calling fsDriver().readDirStats() directly in its delta() method. So I need the separate /api/liststat endpoint.
The driver uses separate header helpers per request type. PUT sends note content which can be binary (attachments, encrypted blobs), so it uses application/octet-stream rather than application/json to avoid corrupting binary payloads.
class FileApiDriverLAN {
private baseUrl_: string;
private authToken_: string;
public constructor(baseUrl: string, authToken: string) {
this.baseUrl_ = baseUrl;
this.authToken_ = authToken;
}
private authHeader_() {
return { Authorization: `Bearer ${this.authToken_}` };
}
private jsonHeaders_() {
return { ...this.authHeader_(), "Content-Type": "application/json" };
}
private binaryHeaders_() {
return { ...this.authHeader_(), "Content-Type": "application/octet-stream" };
}
public async stat(path: string) {
const resp = await fetch(
`${this.baseUrl_}/api/stat/${encodeURIComponent(path)}`,
{ headers: this.jsonHeaders_() },
);
if (resp.status === 404) return null;
return resp.json();
}
public async list(path: string) {
const resp = await fetch(
`${this.baseUrl_}/api/list/${encodeURIComponent(path)}`,
{ headers: this.jsonHeaders_() },
);
return resp.json();
}
public async get(path: string, options: any) {
if (!options) options = {};
const resp = await fetch(
`${this.baseUrl_}/api/get/${encodeURIComponent(path)}`,
{ headers: this.authHeader_() },
);
if (resp.status === 404) return null;
if (options.target === "file") {
const buffer = Buffer.from(await resp.arrayBuffer());
await shim.fsDriver().writeFile(options.path, buffer);
return;
}
return resp.text();
}
public async put(path: string, content: any, options: any) {
if (!options) options = {};
if (options.source === "file") {
content = await shim.fsDriver().readFile(options.path, "Buffer");
}
await fetch(`${this.baseUrl_}/api/put/${encodeURIComponent(path)}`, {
method: "PUT",
headers: this.binaryHeaders_(),
body: content,
});
}
public async delete(path: string) {
await fetch(`${this.baseUrl_}/api/delete/${encodeURIComponent(path)}`, {
method: "DELETE",
headers: this.jsonHeaders_(),
});
}
public async mkdir(path: string) {
await fetch(`${this.baseUrl_}/api/mkdir/${encodeURIComponent(path)}`, {
method: "POST",
headers: this.jsonHeaders_(),
});
}
public async delta(path: string, options: any) {
const getStatFn = async (p: string) => {
const resp = await fetch(
`${this.baseUrl_}/api/liststat/${encodeURIComponent(p)}`,
{ headers: this.jsonHeaders_() },
);
return resp.json();
};
return basicDelta(path, getStatFn, options);
}
}
5. Sync Target Registration (SyncTargetLAN) — Follows the same pattern as SyncTargetFilesystem (~50 lines). Registered in BaseApplication.ts at line 724 alongside existing targets. Settings in builtInMetadata.ts following the sync.{ID}.settingName convention.
export default class SyncTargetLAN extends BaseSyncTarget {
public static id() {
return 12;
}
public static targetName() {
return "lan";
}
public static label() {
return _("LAN Sync");
}
public static description() {
return _("Sync with another device on your local network");
}
public static supportsConfigCheck() {
return true;
}
public static unsupportedPlatforms() {
return [];
}
public async isAuthenticated() {
return !!Setting.value("sync.12.authToken");
}
public async initFileApi() {
const host = Setting.value("sync.12.host");
const port = Setting.value("sync.12.port");
const authToken = Setting.value("sync.12.authToken");
const baseUrl = `https://${host}:${port}`;
const driver = new FileApiDriverLAN(baseUrl, authToken);
const fileApi = new FileApi("", driver);
fileApi.setLogger(this.logger());
fileApi.setSyncTargetId(SyncTargetLAN.id());
return fileApi;
}
public async initSynchronizer() {
return new Synchronizer(
this.db(),
await this.fileApi(),
Setting.value("appType"),
);
}
public static async checkConfig(options: any) {
try {
const baseUrl = `https://${options.host}:${options.port}`;
const driver = new FileApiDriverLAN(baseUrl, options.authToken);
const fileApi = new FileApi("", driver);
fileApi.setSyncTargetId(SyncTargetLAN.id());
fileApi.requestRepeatCount_ = 0;
const result = await fileApi.stat("");
if (!result) throw new Error("Could not reach LAN sync host");
return { ok: true, errorMessage: "" };
} catch (e) {
return { ok: false, errorMessage: e.message };
}
}
}
Changes to the Joplin codebase
| File | Change |
|---|---|
packages/lib/SyncTargetLAN.ts |
New file — sync target class |
packages/lib/file-api-driver-lan.ts |
New file — HTTP client driver |
packages/lib/LanSyncServer.ts |
New file — HTTP server |
packages/lib/LanDiscoveryService.ts |
New file — mDNS discovery |
packages/lib/BaseApplication.ts |
Add SyncTargetRegistry.addClass(SyncTargetLAN) |
packages/lib/models/settings/builtInMetadata.ts |
Add sync.12.* settings: sync.12.host, sync.12.port, sync.12.authToken (secure), sync.12.tlsKey (secure), sync.12.tlsCert (secure, guest cert pinning) |
packages/app-desktop/gui/ |
Settings UI for LAN sync |
packages/app-mobile/ |
Mobile settings + native modules for mDNS/HTTP server |
Libraries
| Library | Platform | Purpose |
|---|---|---|
bonjour-service |
Desktop (Node.js) | DNS-SD discovery. TypeScript, 11M+ weekly downloads, actively maintained |
selfsigned |
Desktop (Node.js) | Self-signed TLS cert generation. Used by webpack-dev-server. Node's built-in crypto can parse certs but cannot create them |
react-native-zeroconf |
Android | mDNS via NsdManager, with DNSSD backend for reliability |
NanoHttpd |
Android | Embedded HTTP server for mobile hosting. Mature and widely embedded (included in Android's source tree), though no longer actively maintained |
react-native-http-bridge-refurbished |
Android | Bridges NanoHttpd to React Native JS. If it doesn't meet our needs, writing a thin custom native module around NanoHttpd is straightforward |
react-native-zeroconf |
iOS | mDNS discovery via Bonjour (built into iOS). Same cross-platform library used on Android — enables iOS as guest with no extra native code |
Potential challenges
- Android mDNS reliability: NsdManager has known platform bugs on some devices. Mitigation:
react-native-zeroconfsupports a DNSSD backend that embeds mDNSResponder directly, bypassing NsdManager. If the library itself proves incompatible, I'll write a custom native module wrapping NsdManager with the specific API surface I need. - React Native New Architecture:
react-native-zeroconfhasn't confirmed compatibility with TurboModules/Fabric (RN 0.76+). I'll test this early in the Android phase and either contribute a compatibility patch upstream or build a minimal native module. - Clock sync between devices:
FileApi.remoteDate()calculates clock offset by writing a temp file and reading its timestamp back. This should work over HTTP since the host returns the file's server-side timestamp. - Network changes mid-sync: If the user switches Wi-Fi networks during sync, HTTP requests will fail. The
Synchronizeralready handles this viatryAndRepeat()retry logic and graceful error reporting.
Security considerations
The threat model assumes an attacker on the same LAN (coffee shop Wi-Fi, shared office, campus network) who can sniff traffic, send requests to the host, and see mDNS broadcasts.
| Threat | Severity | Mitigation |
|---|---|---|
| PIN brute-force (6-digit = 1M combinations) | High | Exponential backoff + lockout after 10 failures + 2-minute PIN expiry. Same rate-limiter-flexible pattern as Joplin Server's limiterLoginBruteForce.ts |
| Token sniffing (eavesdrop on sync traffic) | Medium-High | Self-signed TLS via selfsigned — host generates cert at first launch, guest pins the fingerprint after pairing. All sync traffic encrypted |
| Rogue host (attacker advertises fake service) | Medium | Discovery list shows displayName (...deviceId) for cross-checking. Host-side confirmation dialog before completing pairing |
| Stale trust (lost/stolen device still has token) | Medium | Paired devices list in settings UI with per-device revoke. Token removed immediately on revocation |
Path traversal (../../etc/passwd via API) |
High | sanitizePath_() resolves full path via path.resolve(), then checks against basePath_ + path.sep (not just startsWith(basePath_)) to prevent prefix-match bypass. Null bytes rejected |
Replay attacks are not mitigated separately — TLS prevents token capture in the first place, and an attacker who already has the token has full access regardless of replay protection.
End-to-end sync flow
The guest's Synchronizer drives the entire sync cycle. It talks to FileApiDriverLAN the same way it talks to WebDAV or filesystem drivers — pushing local changes to the host (PUT), deleting remotely removed items (DELETE), then pulling remote changes via delta (GET + /api/liststat). The basicDelta context is persisted and restored by the Synchronizer between sessions, exactly as it does for the filesystem and WebDAV targets.
4. Implementation Plan
Community Bonding (before coding starts): Read Syncthing's discovery protocol source, set up desktop + Android dev environments, discuss API design with @Daeraxa, prototype mDNS discovery on localhost.
| Week | Milestone | Details |
|---|---|---|
| 1 | LanSyncServer | HTTPS server with all endpoints (stat, list, liststat, get, put, delete, mkdir), TLS cert generation via selfsigned, Bearer token auth middleware, sanitizePath_() path traversal protection |
| 2 | FileApiDriverLAN | HTTPS client driver, wire up to FileApi, get basicDelta working with /api/liststat |
| 3 | SyncTargetLAN + tests | Register sync target (ID 12), add settings to builtInMetadata.ts, unit + integration tests, two desktop instances syncing on localhost |
| 4 | mDNS discovery | LanDiscoveryService with bonjour-service, desktop peer discovery working end-to-end |
| 5 | Pairing + Settings UI | 6-digit PIN exchange with rate limiting (exponential backoff + lockout), certificate fingerprint pinning, token storage, paired devices list with revoke, desktop settings panel with host/guest toggle and discovered peers list |
| 6 | Midterm | Desktop-to-desktop LAN sync working end-to-end with discovery and pairing |
| 7 | Desktop polish | checkConfig(), error messages, handle network changes and timeouts, security testing (PIN brute-force, path traversal, token sniffing with TLS) |
| 8 | Android: discovery | Integrate react-native-zeroconf, test mDNS on real Android device |
| 9 | Android: sync + server | FileApiDriverLAN on React Native, foreground service (connectedDevice type) with NanoHttpd |
| 10 | Mobile: UI + testing | Mobile settings screen, pairing flow, desktop-to-Android sync testing, verify iOS guest-only (discovery + sync) |
| 11 | Final polish + docs | Documentation, final E2E testing, address mentor feedback, buffer for unexpected issues |
5. Deliverables
- LAN Sync sync target — fully functional on Windows, Linux, Android, and iOS (guest-only)
- mDNS device discovery — zero-config peer finding on all supported platforms
- PIN-based pairing — secure one-time trust establishment
- Settings UI — integrated into existing sync settings on desktop and mobile
- Tests — unit tests for driver + server, integration tests for full sync cycles, discovery tests on loopback. Following Joplin conventions: one
describe()per file,test.eachfor variations, minimal mocking - Documentation — user-facing docs for setting up LAN Sync
6. Availability
- Weekly availability: 30 hours per week minimum, with capacity to scale up to 40 hours. My summer is fully free — no exams, no internship, no other commitments from June through August. The project requires 175 hours over 11 coding weeks (~16 hours/week), so I have nearly double the capacity needed — plenty of buffer for unexpected complexity or iterating on mentor feedback.
- Timezone: UTC+1 (Tunisia)
- Communication: Daily status updates on the forum GSoC thread. Weekly written progress reports with screenshots or demos. Available on Discord for ad-hoc discussion with mentors.
- Other commitments: None during the GSoC period.
- I'm only applying to Joplin. If accepted, this is what I'm doing.

