make kestrel a tak server, so that it can send and receive pois as cots data
Some checks failed
ci/woodpecker/pr/pr Pipeline failed
Some checks failed
ci/woodpecker/pr/pr Pipeline failed
This commit is contained in:
@@ -66,6 +66,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
cotEntities: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canEditPois: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -81,6 +85,7 @@ const mapContext = ref(null)
|
||||
const markersRef = ref([])
|
||||
const poiMarkersRef = ref({})
|
||||
const liveMarkersRef = ref({})
|
||||
const cotMarkersRef = ref({})
|
||||
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
|
||||
|
||||
const showPoiModal = ref(false)
|
||||
@@ -89,6 +94,7 @@ const addPoiLatlng = ref(null)
|
||||
const editPoi = ref(null)
|
||||
const deletePoi = ref(null)
|
||||
const poiForm = ref({ label: '', iconType: 'pin' })
|
||||
const resizeObserver = ref(null)
|
||||
|
||||
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
|
||||
const TILE_SUBDOMAINS = 'abcd'
|
||||
@@ -135,6 +141,17 @@ function getLiveSessionIcon(L) {
|
||||
})
|
||||
}
|
||||
|
||||
const COT_ICON_COLOR = '#f59e0b' /* amber – ATAK/CoT devices */
|
||||
function getCotEntityIcon(L) {
|
||||
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon cot-entity-icon',
|
||||
html: `<span class="poi-icon-svg">${html}</span>`,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
|
||||
})
|
||||
}
|
||||
|
||||
function createMap(initialCenter) {
|
||||
const { L, offlineApi } = leafletRef.value || {}
|
||||
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
|
||||
@@ -201,6 +218,7 @@ function createMap(initialCenter) {
|
||||
updateMarkers()
|
||||
updatePoiMarkers()
|
||||
updateLiveMarkers()
|
||||
updateCotMarkers()
|
||||
nextTick(() => map.invalidateSize())
|
||||
}
|
||||
|
||||
@@ -291,6 +309,39 @@ function updateLiveMarkers() {
|
||||
liveMarkersRef.value = next
|
||||
}
|
||||
|
||||
function updateCotMarkers() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
if (!ctx?.map || !L) return
|
||||
|
||||
const entities = (props.cotEntities || []).filter(
|
||||
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
|
||||
)
|
||||
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
|
||||
const prev = cotMarkersRef.value
|
||||
const icon = getCotEntityIcon(L)
|
||||
|
||||
Object.keys(prev).forEach((id) => {
|
||||
if (!byId[id]) prev[id]?.remove()
|
||||
})
|
||||
|
||||
const next = entities.reduce((acc, entity) => {
|
||||
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
|
||||
const existing = prev[entity.id]
|
||||
if (existing) {
|
||||
existing.setLatLng([entity.lat, entity.lng])
|
||||
existing.setIcon(icon)
|
||||
existing.getPopup()?.setContent(content)
|
||||
return { ...acc, [entity.id]: existing }
|
||||
}
|
||||
const marker = L.marker([entity.lat, entity.lng], { icon })
|
||||
.addTo(ctx.map)
|
||||
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
|
||||
return { ...acc, [entity.id]: marker }
|
||||
}, {})
|
||||
cotMarkersRef.value = next
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
@@ -376,6 +427,8 @@ function destroyMap() {
|
||||
poiMarkersRef.value = {}
|
||||
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
|
||||
liveMarkersRef.value = {}
|
||||
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
|
||||
cotMarkersRef.value = {}
|
||||
|
||||
const ctx = mapContext.value
|
||||
if (ctx) {
|
||||
@@ -404,8 +457,6 @@ function initMapWithLocation() {
|
||||
)
|
||||
}
|
||||
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.client || typeof document === 'undefined') return
|
||||
const [leaflet, offline] = await Promise.all([
|
||||
@@ -428,10 +479,10 @@ onMounted(async () => {
|
||||
|
||||
nextTick(() => {
|
||||
if (mapRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
resizeObserver.value = new ResizeObserver(() => {
|
||||
mapContext.value?.map?.invalidateSize()
|
||||
})
|
||||
resizeObserver.observe(mapRef.value)
|
||||
resizeObserver.value.observe(mapRef.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -442,9 +493,9 @@ function onDocumentClick(e) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
if (resizeObserver && mapRef.value) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
if (resizeObserver.value && mapRef.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
resizeObserver.value = null
|
||||
}
|
||||
destroyMap()
|
||||
})
|
||||
@@ -452,4 +503,5 @@ onBeforeUnmount(() => {
|
||||
watch(() => props.devices, () => updateMarkers(), { deep: true })
|
||||
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
||||
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
||||
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
|
||||
</script>
|
||||
|
||||
@@ -104,9 +104,9 @@ const hasStream = ref(false)
|
||||
const error = ref('')
|
||||
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
|
||||
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
||||
let device = null
|
||||
let recvTransport = null
|
||||
let consumer = null
|
||||
const device = ref(null)
|
||||
const recvTransport = ref(null)
|
||||
const consumer = ref(null)
|
||||
|
||||
async function runFailureReasonCheck() {
|
||||
failureReason.value = await getWebRTCFailureReason()
|
||||
@@ -135,16 +135,16 @@ async function setupWebRTC() {
|
||||
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
device = await createMediasoupDevice(rtpCapabilities)
|
||||
recvTransport = await createRecvTransport(device, props.session.id)
|
||||
device.value = await createMediasoupDevice(rtpCapabilities)
|
||||
recvTransport.value = await createRecvTransport(device.value, props.session.id)
|
||||
|
||||
recvTransport.on('connectionstatechange', () => {
|
||||
const state = recvTransport.connectionState
|
||||
recvTransport.value.on('connectionstatechange', () => {
|
||||
const state = recvTransport.value.connectionState
|
||||
if (state === 'connected') connectionState.value = 'connected'
|
||||
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('LiveSessionPanel: Receive transport connection state changed', {
|
||||
state,
|
||||
transportId: recvTransport.id,
|
||||
transportId: recvTransport.value.id,
|
||||
sessionId: props.session.id,
|
||||
})
|
||||
if (state === 'failed') {
|
||||
@@ -154,8 +154,8 @@ async function setupWebRTC() {
|
||||
}
|
||||
})
|
||||
|
||||
const connectionPromise = waitForConnectionState(recvTransport, 10000)
|
||||
consumer = await consumeProducer(recvTransport, device, props.session.id)
|
||||
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
|
||||
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
|
||||
const finalConnectionState = await connectionPromise
|
||||
|
||||
if (finalConnectionState !== 'connected') {
|
||||
@@ -163,8 +163,8 @@ async function setupWebRTC() {
|
||||
runFailureReasonCheck()
|
||||
logWarn('LiveSessionPanel: Transport not fully connected', {
|
||||
state: finalConnectionState,
|
||||
transportId: recvTransport.id,
|
||||
consumerId: consumer.id,
|
||||
transportId: recvTransport.value.id,
|
||||
consumerId: consumer.value.id,
|
||||
})
|
||||
}
|
||||
else {
|
||||
@@ -182,14 +182,14 @@ async function setupWebRTC() {
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (!consumer.track) {
|
||||
if (!consumer.value.track) {
|
||||
logError('LiveSessionPanel: No video track available', {
|
||||
consumerId: consumer.id,
|
||||
consumerKind: consumer.kind,
|
||||
consumerPaused: consumer.paused,
|
||||
consumerClosed: consumer.closed,
|
||||
consumerProducerId: consumer.producerId,
|
||||
transportConnectionState: recvTransport?.connectionState,
|
||||
consumerId: consumer.value.id,
|
||||
consumerKind: consumer.value.kind,
|
||||
consumerPaused: consumer.value.paused,
|
||||
consumerClosed: consumer.value.closed,
|
||||
consumerProducerId: consumer.value.producerId,
|
||||
transportConnectionState: recvTransport.value?.connectionState,
|
||||
})
|
||||
error.value = 'No video track available - consumer may not be receiving data from producer'
|
||||
return
|
||||
@@ -197,14 +197,14 @@ async function setupWebRTC() {
|
||||
|
||||
if (!videoRef.value) {
|
||||
logError('LiveSessionPanel: Video ref not available', {
|
||||
consumerId: consumer.id,
|
||||
hasTrack: !!consumer.track,
|
||||
consumerId: consumer.value.id,
|
||||
hasTrack: !!consumer.value.track,
|
||||
})
|
||||
error.value = 'Video element not available'
|
||||
return
|
||||
}
|
||||
|
||||
const stream = new MediaStream([consumer.track])
|
||||
const stream = new MediaStream([consumer.value.track])
|
||||
videoRef.value.srcObject = stream
|
||||
hasStream.value = true
|
||||
|
||||
@@ -227,7 +227,7 @@ async function setupWebRTC() {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
videoRef.value.removeEventListener('loadedmetadata', handler)
|
||||
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id })
|
||||
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
|
||||
resolve()
|
||||
}, 5000)
|
||||
})
|
||||
@@ -239,7 +239,7 @@ async function setupWebRTC() {
|
||||
}
|
||||
catch (playErr) {
|
||||
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
|
||||
consumerId: consumer.id,
|
||||
consumerId: consumer.value.id,
|
||||
error: playErr.message || String(playErr),
|
||||
errorName: playErr.name,
|
||||
videoPaused: videoRef.value.paused,
|
||||
@@ -248,7 +248,7 @@ async function setupWebRTC() {
|
||||
// Don't set error - video might still work, just needs user interaction
|
||||
}
|
||||
|
||||
consumer.track.addEventListener('ended', () => {
|
||||
consumer.value.track.addEventListener('ended', () => {
|
||||
error.value = 'Video track ended'
|
||||
hasStream.value = false
|
||||
})
|
||||
@@ -274,15 +274,15 @@ async function setupWebRTC() {
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (consumer) {
|
||||
consumer.close()
|
||||
consumer = null
|
||||
if (consumer.value) {
|
||||
consumer.value.close()
|
||||
consumer.value = null
|
||||
}
|
||||
if (recvTransport) {
|
||||
recvTransport.close()
|
||||
recvTransport = null
|
||||
if (recvTransport.value) {
|
||||
recvTransport.value.close()
|
||||
recvTransport.value = null
|
||||
}
|
||||
device = null
|
||||
device.value = null
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null
|
||||
}
|
||||
@@ -308,7 +308,7 @@ watch(
|
||||
watch(
|
||||
() => props.session?.hasStream,
|
||||
(hasStream) => {
|
||||
if (hasStream && props.session?.id && !device) {
|
||||
if (hasStream && props.session?.id && !device.value) {
|
||||
setupWebRTC()
|
||||
}
|
||||
else if (!hasStream) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Fetches devices + live sessions; polls when tab visible. */
|
||||
const POLL_MS = 1500
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
|
||||
|
||||
export function useCameras(options = {}) {
|
||||
const { poll: enablePoll = true } = options
|
||||
@@ -12,6 +12,7 @@ export function useCameras(options = {}) {
|
||||
|
||||
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
|
||||
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
|
||||
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
|
||||
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
|
||||
|
||||
const pollInterval = ref(null)
|
||||
@@ -36,5 +37,5 @@ export function useCameras(options = {}) {
|
||||
})
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
|
||||
return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
*/
|
||||
export function useMediaQuery(query) {
|
||||
const matches = ref(true)
|
||||
let mql = null
|
||||
const mql = ref(null)
|
||||
const handler = (e) => {
|
||||
matches.value = e.matches
|
||||
}
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia(query)
|
||||
matches.value = mql.matches
|
||||
mql.addEventListener('change', handler)
|
||||
mql.value = window.matchMedia(query)
|
||||
matches.value = mql.value.matches
|
||||
mql.value.addEventListener('change', handler)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (mql) mql.removeEventListener('change', handler)
|
||||
if (mql.value) mql.value.removeEventListener('change', handler)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
@@ -186,18 +186,18 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
|
||||
export function waitForConnectionState(transport, timeoutMs = 10000) {
|
||||
const terminal = ['connected', 'failed', 'disconnected', 'closed']
|
||||
return new Promise((resolve) => {
|
||||
let tid
|
||||
const tid = ref(null)
|
||||
const handler = () => {
|
||||
const state = transport.connectionState
|
||||
if (terminal.includes(state)) {
|
||||
transport.off('connectionstatechange', handler)
|
||||
if (tid) clearTimeout(tid)
|
||||
if (tid.value) clearTimeout(tid.value)
|
||||
resolve(state)
|
||||
}
|
||||
}
|
||||
transport.on('connectionstatechange', handler)
|
||||
handler()
|
||||
tid = setTimeout(() => {
|
||||
tid.value = setTimeout(() => {
|
||||
transport.off('connectionstatechange', handler)
|
||||
resolve(transport.connectionState)
|
||||
}, timeoutMs)
|
||||
|
||||
@@ -94,6 +94,71 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="user"
|
||||
class="mb-8"
|
||||
>
|
||||
<h3 class="kestrel-section-label">
|
||||
ATAK / device password
|
||||
</h3>
|
||||
<div class="kestrel-card p-4">
|
||||
<p class="mb-3 text-sm text-kestrel-muted">
|
||||
{{ user.auth_provider === 'oidc' ? 'Set a password to use when connecting from ATAK (check "Use Authentication" and enter your KestrelOS username and this password).' : 'Optionally set a separate password for ATAK; otherwise use your login password.' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="cotPasswordSuccess"
|
||||
class="mb-3 text-sm text-green-400"
|
||||
>
|
||||
ATAK password saved.
|
||||
</p>
|
||||
<p
|
||||
v-if="cotPasswordError"
|
||||
class="mb-3 text-sm text-red-400"
|
||||
>
|
||||
{{ cotPasswordError }}
|
||||
</p>
|
||||
<form
|
||||
class="space-y-3"
|
||||
@submit.prevent="onSetCotPassword"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="account-cot-password"
|
||||
class="kestrel-label"
|
||||
>ATAK password</label>
|
||||
<input
|
||||
id="account-cot-password"
|
||||
v-model="cotPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
:placeholder="user.auth_provider === 'oidc' ? 'Set password for ATAK' : 'Optional'"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="account-cot-password-confirm"
|
||||
class="kestrel-label"
|
||||
>Confirm ATAK password</label>
|
||||
<input
|
||||
id="account-cot-password-confirm"
|
||||
v-model="cotPasswordConfirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="cotPasswordLoading"
|
||||
>
|
||||
{{ cotPasswordLoading ? 'Saving…' : 'Save ATAK password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="user?.auth_provider === 'local'"
|
||||
class="mb-8"
|
||||
@@ -181,6 +246,11 @@ const confirmPassword = ref('')
|
||||
const passwordLoading = ref(false)
|
||||
const passwordSuccess = ref(false)
|
||||
const passwordError = ref('')
|
||||
const cotPassword = ref('')
|
||||
const cotPasswordConfirm = ref('')
|
||||
const cotPasswordLoading = ref(false)
|
||||
const cotPasswordSuccess = ref(false)
|
||||
const cotPasswordError = ref('')
|
||||
|
||||
const accountInitials = computed(() => {
|
||||
const id = user.value?.identifier ?? ''
|
||||
@@ -254,4 +324,34 @@ async function onChangePassword() {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetCotPassword() {
|
||||
cotPasswordError.value = ''
|
||||
cotPasswordSuccess.value = false
|
||||
if (cotPassword.value !== cotPasswordConfirm.value) {
|
||||
cotPasswordError.value = 'Password and confirmation do not match.'
|
||||
return
|
||||
}
|
||||
if (cotPassword.value.length < 1) {
|
||||
cotPasswordError.value = 'Password cannot be empty.'
|
||||
return
|
||||
}
|
||||
cotPasswordLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/me/cot-password', {
|
||||
method: 'PUT',
|
||||
body: { password: cotPassword.value },
|
||||
credentials: 'include',
|
||||
})
|
||||
cotPassword.value = ''
|
||||
cotPasswordConfirm.value = ''
|
||||
cotPasswordSuccess.value = true
|
||||
}
|
||||
catch (e) {
|
||||
cotPasswordError.value = e.data?.message ?? e.message ?? 'Failed to save ATAK password.'
|
||||
}
|
||||
finally {
|
||||
cotPasswordLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:devices="devices ?? []"
|
||||
:pois="pois ?? []"
|
||||
:live-sessions="liveSessions ?? []"
|
||||
:cot-entities="cotEntities ?? []"
|
||||
:can-edit-pois="canEditPois"
|
||||
@select="selectedCamera = $event"
|
||||
@select-live="onSelectLive($event)"
|
||||
@@ -22,7 +23,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { devices, liveSessions } = useCameras()
|
||||
const { devices, liveSessions, cotEntities } = useCameras()
|
||||
const { data: pois, refresh: refreshPois } = usePois()
|
||||
const { canEditPois } = useUser()
|
||||
const selectedCamera = ref(null)
|
||||
|
||||
@@ -36,6 +36,67 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="kestrel-section-label">
|
||||
TAK Server (ATAK / iTAK)
|
||||
</h3>
|
||||
<div class="kestrel-card p-4">
|
||||
<p class="mb-3 text-sm text-kestrel-text">
|
||||
Scan this QR code with iTAK (or ATAK) to add this KestrelOS server. You’ll be prompted for your KestrelOS username and password after scanning.
|
||||
</p>
|
||||
<div
|
||||
v-if="takQrDataUrl"
|
||||
class="inline-block rounded-lg border border-kestrel-border bg-white p-3"
|
||||
>
|
||||
<img
|
||||
:src="takQrDataUrl"
|
||||
alt="TAK Server QR code"
|
||||
class="h-48 w-48"
|
||||
width="192"
|
||||
height="192"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="takQrError"
|
||||
class="text-sm text-red-400"
|
||||
>
|
||||
{{ takQrError }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
Loading QR code…
|
||||
</p>
|
||||
<p
|
||||
v-if="takServerString"
|
||||
class="mt-3 text-xs text-kestrel-muted break-all"
|
||||
>
|
||||
{{ takServerString }}
|
||||
</p>
|
||||
<template v-if="cotConfig?.ssl">
|
||||
<p class="mt-3 text-sm text-kestrel-text">
|
||||
This server uses a self-signed certificate. iTAK will not connect until it trusts the cert.
|
||||
</p>
|
||||
<ol class="mt-2 list-decimal list-inside space-y-1 text-sm text-kestrel-text">
|
||||
<li>
|
||||
<strong>Upload server package:</strong> Download below, then in iTAK tap Add Server (+) → Upload server package and select the zip; enter KestrelOS username and password when prompted.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Plain TCP:</strong> Remove or rename <code class="bg-kestrel-surface px-1 rounded">.dev-certs</code>, restart, then in iTAK add the server with SSL disabled.
|
||||
</li>
|
||||
</ol>
|
||||
<a
|
||||
href="/api/cot/server-package"
|
||||
download="kestrelos-itak-server-package.zip"
|
||||
class="kestrel-btn-secondary mt-3 inline-block"
|
||||
>
|
||||
Download server package (zip)
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="kestrel-section-label">
|
||||
About
|
||||
@@ -67,6 +128,11 @@ const tilesMessage = ref('')
|
||||
const tilesMessageSuccess = ref(false)
|
||||
const tilesLoading = ref(false)
|
||||
|
||||
const cotConfig = ref(null)
|
||||
const takQrDataUrl = ref('')
|
||||
const takQrError = ref('')
|
||||
const takServerString = ref('')
|
||||
|
||||
async function loadTilesStored() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -106,7 +172,26 @@ async function onClearTiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTakQr() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const res = await $fetch('/api/cot/config')
|
||||
cotConfig.value = res
|
||||
const hostname = window.location.hostname
|
||||
const port = res?.port ?? 8089
|
||||
const protocol = res?.ssl ? 'ssl' : 'tcp'
|
||||
const str = `KestrelOS,${hostname},${port},${protocol}`
|
||||
takServerString.value = str
|
||||
const QRCode = (await import('qrcode')).default
|
||||
takQrDataUrl.value = await QRCode.toDataURL(str, { width: 192, margin: 1 })
|
||||
}
|
||||
catch (e) {
|
||||
takQrError.value = e?.data?.error ?? e?.message ?? 'Could not load TAK server config.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTilesStored()
|
||||
loadTakQr()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -122,11 +122,11 @@ const starting = ref(false)
|
||||
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
|
||||
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
|
||||
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
||||
let locationWatchId = null
|
||||
let locationIntervalId = null
|
||||
let device = null
|
||||
let sendTransport = null
|
||||
let producer = null
|
||||
const locationWatchId = ref(null)
|
||||
const locationIntervalId = ref(null)
|
||||
const device = ref(null)
|
||||
const sendTransport = ref(null)
|
||||
const producer = ref(null)
|
||||
|
||||
async function runFailureReasonCheck() {
|
||||
webrtcFailureReason.value = await getWebRTCFailureReason()
|
||||
@@ -194,8 +194,8 @@ async function startSharing() {
|
||||
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
device = await createMediasoupDevice(rtpCapabilities)
|
||||
sendTransport = await createSendTransport(device, sessionId.value, {
|
||||
device.value = await createMediasoupDevice(rtpCapabilities)
|
||||
sendTransport.value = await createSendTransport(device.value, sessionId.value, {
|
||||
onConnectSuccess: () => { webrtcState.value = 'connected' },
|
||||
onConnectFailure: () => {
|
||||
webrtcState.value = 'failed'
|
||||
@@ -208,31 +208,31 @@ async function startSharing() {
|
||||
if (!videoTrack) {
|
||||
throw new Error('No video track available')
|
||||
}
|
||||
producer = await sendTransport.produce({ track: videoTrack })
|
||||
producer.value = await sendTransport.value.produce({ track: videoTrack })
|
||||
// Monitor producer events
|
||||
producer.on('transportclose', () => {
|
||||
producer.value.on('transportclose', () => {
|
||||
logWarn('share-live: Producer transport closed', {
|
||||
producerId: producer.id,
|
||||
producerPaused: producer.paused,
|
||||
producerClosed: producer.closed,
|
||||
producerId: producer.value.id,
|
||||
producerPaused: producer.value.paused,
|
||||
producerClosed: producer.value.closed,
|
||||
})
|
||||
})
|
||||
producer.on('trackended', () => {
|
||||
producer.value.on('trackended', () => {
|
||||
logWarn('share-live: Producer track ended', {
|
||||
producerId: producer.id,
|
||||
producerPaused: producer.paused,
|
||||
producerClosed: producer.closed,
|
||||
producerId: producer.value.id,
|
||||
producerPaused: producer.value.paused,
|
||||
producerClosed: producer.value.closed,
|
||||
})
|
||||
})
|
||||
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
|
||||
sendTransport.on('connectionstatechange', () => {
|
||||
const state = sendTransport.connectionState
|
||||
sendTransport.value.on('connectionstatechange', () => {
|
||||
const state = sendTransport.value.connectionState
|
||||
if (state === 'connected') webrtcState.value = 'connected'
|
||||
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('share-live: Send transport connection state changed', {
|
||||
state,
|
||||
transportId: sendTransport.id,
|
||||
producerId: producer.id,
|
||||
transportId: sendTransport.value.id,
|
||||
producerId: producer.value.id,
|
||||
})
|
||||
if (state === 'failed') {
|
||||
webrtcState.value = 'failed'
|
||||
@@ -241,25 +241,25 @@ async function startSharing() {
|
||||
}
|
||||
})
|
||||
// Monitor track state
|
||||
if (producer.track) {
|
||||
producer.track.addEventListener('ended', () => {
|
||||
if (producer.value.track) {
|
||||
producer.value.track.addEventListener('ended', () => {
|
||||
logWarn('share-live: Producer track ended', {
|
||||
producerId: producer.id,
|
||||
trackId: producer.track.id,
|
||||
trackReadyState: producer.track.readyState,
|
||||
trackEnabled: producer.track.enabled,
|
||||
trackMuted: producer.track.muted,
|
||||
producerId: producer.value.id,
|
||||
trackId: producer.value.track.id,
|
||||
trackReadyState: producer.value.track.readyState,
|
||||
trackEnabled: producer.value.track.enabled,
|
||||
trackMuted: producer.value.track.muted,
|
||||
})
|
||||
})
|
||||
producer.track.addEventListener('mute', () => {
|
||||
producer.value.track.addEventListener('mute', () => {
|
||||
logWarn('share-live: Producer track muted', {
|
||||
producerId: producer.id,
|
||||
trackId: producer.track.id,
|
||||
trackEnabled: producer.track.enabled,
|
||||
trackMuted: producer.track.muted,
|
||||
producerId: producer.value.id,
|
||||
trackId: producer.value.track.id,
|
||||
trackEnabled: producer.value.track.enabled,
|
||||
trackMuted: producer.value.track.muted,
|
||||
})
|
||||
})
|
||||
producer.track.addEventListener('unmute', () => {})
|
||||
producer.value.track.addEventListener('unmute', () => {})
|
||||
}
|
||||
webrtcState.value = 'connected'
|
||||
setStatus('WebRTC connected. Requesting location…')
|
||||
@@ -281,7 +281,7 @@ async function startSharing() {
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
locationWatchId = navigator.geolocation.watchPosition(
|
||||
locationWatchId.value = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
resolve(pos)
|
||||
},
|
||||
@@ -332,9 +332,9 @@ async function startSharing() {
|
||||
}
|
||||
catch (e) {
|
||||
if (e?.statusCode === 404) {
|
||||
if (locationIntervalId != null) {
|
||||
clearInterval(locationIntervalId)
|
||||
locationIntervalId = null
|
||||
if (locationIntervalId.value != null) {
|
||||
clearInterval(locationIntervalId.value)
|
||||
locationIntervalId.value = null
|
||||
}
|
||||
sharing.value = false
|
||||
if (!locationUpdate404Logged) {
|
||||
@@ -350,7 +350,7 @@ async function startSharing() {
|
||||
}
|
||||
|
||||
await sendLocationUpdate()
|
||||
locationIntervalId = setInterval(sendLocationUpdate, 2000)
|
||||
locationIntervalId.value = setInterval(sendLocationUpdate, 2000)
|
||||
}
|
||||
catch (e) {
|
||||
starting.value = false
|
||||
@@ -363,23 +363,23 @@ async function startSharing() {
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
|
||||
navigator.geolocation.clearWatch(locationWatchId)
|
||||
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
|
||||
navigator.geolocation.clearWatch(locationWatchId.value)
|
||||
}
|
||||
locationWatchId = null
|
||||
if (locationIntervalId != null) {
|
||||
clearInterval(locationIntervalId)
|
||||
locationWatchId.value = null
|
||||
if (locationIntervalId.value != null) {
|
||||
clearInterval(locationIntervalId.value)
|
||||
}
|
||||
locationIntervalId = null
|
||||
if (producer) {
|
||||
producer.close()
|
||||
producer = null
|
||||
locationIntervalId.value = null
|
||||
if (producer.value) {
|
||||
producer.value.close()
|
||||
producer.value = null
|
||||
}
|
||||
if (sendTransport) {
|
||||
sendTransport.close()
|
||||
sendTransport = null
|
||||
if (sendTransport.value) {
|
||||
sendTransport.value.close()
|
||||
sendTransport.value = null
|
||||
}
|
||||
device = null
|
||||
device.value = null
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach(t => t.stop())
|
||||
stream.value = null
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/** Client-side logger: sends to server, falls back to console. */
|
||||
let sessionId = null
|
||||
let userId = null
|
||||
const sessionId = ref(null)
|
||||
const userId = ref(null)
|
||||
|
||||
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
|
||||
|
||||
export function initLogger(sessId, uid) {
|
||||
sessionId = sessId
|
||||
userId = uid
|
||||
sessionId.value = sessId
|
||||
userId.value = uid
|
||||
}
|
||||
|
||||
function sendToServer(level, message, data) {
|
||||
setTimeout(() => {
|
||||
$fetch('/api/log', {
|
||||
method: 'POST',
|
||||
body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
|
||||
body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() },
|
||||
credentials: 'include',
|
||||
}).catch(() => { /* server down - don't spam console */ })
|
||||
}, 0)
|
||||
|
||||
Reference in New Issue
Block a user