CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The Account page offered the APK and nothing else, because the APK was all the server held. Step 3 baked in four more, so the single card had to become a section — and five artifacts is exactly where a downloads page turns into a table of filenames and stops being a product. So it LEADS with what fits the machine asking, from the user agent, and keeps the rest quiet but visible. A wrong guess costs nothing: nothing is behind a disclosure and every other client is one click away. Linux gets all three at once, because the UA says "Linux" and nothing about dpkg or pacman — there is no better answer available. They are named for the distro rather than the package format, since a person knows which system they run and not necessarily which packaging it uses. The AppImage carries one clause of its own: it is 95 MB against 3, and it is also the only bundle that updates itself in place. Both facts belong to the same decision. macOS and iOS lead with nothing and say so. There is no build for either, and "There's no macOS build yet" is the difference between deliberate and broken. The version renders `unknown` rather than blank, and the download stays offered — not knowing which build it is, is not a reason to withhold it. Two things this did NOT do, both deliberate: The task asked for a Tauri case — do not offer the desktop app to someone already running it. That case cannot be reached: `/account` redirects to the board in the desktop app (requiresServer, router/index.ts), because device tokens are a server-side concept. A branch for it would be dead code. `.btn-link` mirrors BaseButton's declarations rather than replacing them. BaseButton is a <button> and cannot carry an href, and unifying the two would have put every button in the app into an operator pass that CI cannot check — for a cosmetic gain. The comment names the pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
244 lines
8.1 KiB
Vue
244 lines
8.1 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref } from "vue";
|
|
import { useConfigStore } from "../stores/config";
|
|
import { useDevicesStore } from "../stores/devices";
|
|
import { useUiStore } from "../stores/ui";
|
|
import BaseButton from "../components/BaseButton.vue";
|
|
import BaseInput from "../components/BaseInput.vue";
|
|
import ClientDownloads from "../components/ClientDownloads.vue";
|
|
import Icon from "../components/Icon.vue";
|
|
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
|
|
|
|
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
|
// Android apps authenticate sync with a device bearer token issued here.
|
|
const devices = useDevicesStore();
|
|
const ui = useUiStore();
|
|
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
|
|
// component that fetches its own config would race the one that does.
|
|
const config = useConfigStore();
|
|
|
|
const error = ref("");
|
|
const newName = ref("");
|
|
const creating = ref(false);
|
|
// The freshly-issued plaintext token — shown ONCE (never retrievable again).
|
|
const freshToken = ref("");
|
|
|
|
// Desktop-app self-integration (Linux AppImage only; the card is hidden otherwise).
|
|
const desktopStatus = ref<IntegrationStatus | null>(null);
|
|
const desktopBusy = ref(false);
|
|
|
|
async function load() {
|
|
error.value = "";
|
|
try {
|
|
await config.load();
|
|
await devices.load();
|
|
} catch {
|
|
error.value = "Couldn't load your linked devices.";
|
|
}
|
|
}
|
|
|
|
async function link() {
|
|
creating.value = true;
|
|
error.value = "";
|
|
freshToken.value = "";
|
|
try {
|
|
freshToken.value = await devices.create(newName.value.trim() || "Device");
|
|
newName.value = "";
|
|
} catch (e) {
|
|
error.value = (e as { error?: string }).error ?? "Couldn't create a device token.";
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
}
|
|
|
|
async function copyToken() {
|
|
try {
|
|
await navigator.clipboard.writeText(freshToken.value);
|
|
ui.showToast("Token copied to clipboard.");
|
|
} catch {
|
|
ui.showToast("Couldn't copy — select and copy it manually.");
|
|
}
|
|
}
|
|
|
|
async function revoke(id: string, name: string) {
|
|
if (!window.confirm(`Revoke "${name}"? That device will need to link again to sync.`)) return;
|
|
try {
|
|
await devices.revoke(id);
|
|
} catch {
|
|
ui.showToast("Couldn't revoke that device.");
|
|
}
|
|
}
|
|
|
|
function fmt(iso: string | null): string {
|
|
if (!iso) return "never";
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
|
|
async function loadDesktop() {
|
|
if (!isDesktop()) return;
|
|
try {
|
|
desktopStatus.value = await desktopBridge.integrationStatus();
|
|
} catch {
|
|
desktopStatus.value = null;
|
|
}
|
|
}
|
|
|
|
async function toggleIntegration() {
|
|
desktopBusy.value = true;
|
|
try {
|
|
desktopStatus.value = desktopStatus.value?.is_integrated
|
|
? await desktopBridge.unintegrate()
|
|
: await desktopBridge.integrate();
|
|
ui.showToast(
|
|
desktopStatus.value?.is_integrated
|
|
? "Added to your applications menu."
|
|
: "Removed from your applications menu.",
|
|
);
|
|
} catch (e) {
|
|
ui.showToast((e as { message?: string }).message ?? "Couldn't update the menu entry.");
|
|
} finally {
|
|
desktopBusy.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void load();
|
|
void loadDesktop();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
|
<header class="mb-8 flex items-center gap-3">
|
|
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
|
<svg
|
|
class="h-[18px] w-[18px]"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
</RouterLink>
|
|
<h1 class="text-xl font-bold tracking-tight">Linked devices</h1>
|
|
</header>
|
|
|
|
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
|
|
Link the ThoughtSync desktop or mobile app to sync your notes. Create a device token here, then
|
|
paste it into the app when it asks to connect. You can revoke a device at any time.
|
|
</p>
|
|
|
|
<!-- Desktop app self-integration (Linux AppImage only) -->
|
|
<section
|
|
v-if="desktopStatus?.is_appimage"
|
|
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
|
|
>
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Desktop app</p>
|
|
<p class="mt-0.5 text-xs text-neutral-400">
|
|
{{
|
|
desktopStatus.is_integrated
|
|
? "ThoughtSync is in your applications menu."
|
|
: "Add ThoughtSync to your applications menu to launch it like an installed app."
|
|
}}
|
|
</p>
|
|
</div>
|
|
<BaseButton
|
|
:variant="desktopStatus.is_integrated ? 'ghost' : 'primary'"
|
|
:loading="desktopBusy"
|
|
class="shrink-0"
|
|
@click="toggleIntegration"
|
|
>
|
|
{{ desktopStatus.is_integrated ? "Remove" : "Add to applications" }}
|
|
</BaseButton>
|
|
</section>
|
|
|
|
<!-- Every client this server holds, the one that fits this machine on top.
|
|
Hides itself when the server holds none. -->
|
|
<ClientDownloads />
|
|
|
|
<!-- One-time token reveal -->
|
|
<div
|
|
v-if="freshToken"
|
|
class="mb-6 rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10"
|
|
>
|
|
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
Copy this token now — it won't be shown again.
|
|
</p>
|
|
<div class="mt-2 flex items-center gap-2">
|
|
<code
|
|
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
|
|
>{{ freshToken }}</code
|
|
>
|
|
<button
|
|
type="button"
|
|
class="icon-btn shrink-0"
|
|
title="Copy token"
|
|
aria-label="Copy token"
|
|
@click="copyToken"
|
|
>
|
|
<Icon name="copy" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
|
|
@click="freshToken = ''"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Create a device token -->
|
|
<form class="mb-8 flex items-end gap-3" @submit.prevent="link">
|
|
<BaseInput
|
|
id="device-name"
|
|
v-model="newName"
|
|
label="Link a new device"
|
|
placeholder="e.g. My laptop, Pixel phone"
|
|
class="flex-1"
|
|
/>
|
|
<BaseButton type="submit" :loading="creating">Create token</BaseButton>
|
|
</form>
|
|
|
|
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
|
|
|
<!-- Device list -->
|
|
<div v-if="devices.loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
|
|
<div
|
|
v-else-if="!devices.items.length"
|
|
class="rounded-xl border border-dashed border-neutral-300 py-10 text-center dark:border-neutral-700"
|
|
>
|
|
<p class="text-sm text-neutral-500 dark:text-neutral-400">No devices linked yet.</p>
|
|
</div>
|
|
<ul v-else class="flex flex-col gap-2">
|
|
<li
|
|
v-for="d in devices.items"
|
|
:key="d.id"
|
|
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<span class="text-neutral-400"><Icon name="device" /></span>
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ d.name }}</p>
|
|
<p class="text-xs text-neutral-400">
|
|
Linked {{ fmt(d.created_at) }} · last synced {{ fmt(d.last_used_at) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
|
|
@click="revoke(d.id, d.name)"
|
|
>
|
|
Revoke
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|