mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
Compare commits
33 Commits
Author | SHA1 | Date | |
---|---|---|---|
7cc53a0429 | |||
014b68e1fb | |||
5c683fb1b8 | |||
e26e4d2004 | |||
5f698330fc | |||
f32fc989b9 | |||
d1f1be4e86 | |||
e754e66878 | |||
26af1dc7f5 | |||
0c9055e4f6 | |||
c200326512 | |||
b1ce755210 | |||
2b13220d63 | |||
db6f9c957e | |||
798bf579c0 | |||
2f9964580f | |||
b8881b3a46 | |||
355c0eac0d | |||
4d023df692 | |||
792b1ac111 | |||
a03b29ba01 | |||
683b750189 | |||
3c4d7b9d60 | |||
7a9b33706a | |||
052a961fd9 | |||
8ef9c1aff5 | |||
6ed3493aa0 | |||
5959d056f3 | |||
92ad202bfe | |||
cd4acb5eec | |||
a32be0b44a | |||
b2080649ea | |||
851c38b8fd |
@ -27,7 +27,7 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# build server
|
||||
COPY server/ .
|
||||
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# STAGE 2: CLIENT
|
||||
|
@ -27,29 +27,32 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# build server
|
||||
COPY server/ .
|
||||
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# STAGE 2: CLIENT
|
||||
#
|
||||
FROM node:18-bullseye-slim as client
|
||||
|
||||
# install dependencies
|
||||
RUN set -eux; apt-get update; \
|
||||
apt-get install -y --no-install-recommends python2 build-essential
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
#
|
||||
# install dependencies
|
||||
COPY client/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Because client builds fail in Github Actions, therefor we build it outside of Docker.
|
||||
#
|
||||
# FROM node:18-bullseye-slim as client
|
||||
#
|
||||
# # install dependencies
|
||||
# RUN set -eux; apt-get update; \
|
||||
# apt-get install -y --no-install-recommends python2 build-essential
|
||||
#
|
||||
# WORKDIR /src
|
||||
#
|
||||
# #
|
||||
# # install dependencies
|
||||
# COPY client/package*.json ./
|
||||
# RUN npm install
|
||||
#
|
||||
# #
|
||||
# # build client
|
||||
# COPY client/ .
|
||||
# RUN npm run build
|
||||
#
|
||||
# build client
|
||||
COPY client/ .
|
||||
RUN npm run build
|
||||
|
||||
#
|
||||
# STAGE 3: RUNTIME
|
||||
#
|
||||
@ -134,7 +137,8 @@ ENV NEKO_BIND=:8080
|
||||
#
|
||||
# copy static files from previous stages
|
||||
COPY --from=server /src/bin/neko /usr/bin/neko
|
||||
COPY --from=client /src/dist/ /var/www
|
||||
# COPY --from=client /src/dist/ /var/www
|
||||
COPY client/dist/ /var/www
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=8 \
|
||||
CMD wget -O - http://localhost:${NEKO_BIND#*:}/health || exit 1
|
||||
|
@ -27,7 +27,7 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# build server
|
||||
COPY server/ .
|
||||
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# STAGE 2: CLIENT
|
||||
|
@ -82,7 +82,7 @@ RUN set -eux; apt-get update; \
|
||||
#
|
||||
# build server
|
||||
COPY server/ .
|
||||
RUN go get -v -t -d . && go build -o bin/neko cmd/neko/main.go
|
||||
RUN ./build
|
||||
|
||||
#
|
||||
# STAGE 2: CLIENT
|
||||
|
@ -10,7 +10,7 @@ RUN set -eux; \
|
||||
#
|
||||
# install widevine module
|
||||
CHROMIUM_DIR="/usr/lib/chromium"; \
|
||||
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | tail -n 1); \
|
||||
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | sort --version-sort | tail -n 1); \
|
||||
wget -O /tmp/widevine.zip "https://dl.google.com/widevine-cdm/${WIDEVINE_VERSION}-linux-x64.zip"; \
|
||||
mkdir -p "${CHROMIUM_DIR}/WidevineCdm/_platform_specific/linux_x64"; \
|
||||
unzip -p /tmp/widevine.zip LICENSE.txt > "${CHROMIUM_DIR}/WidevineCdm/LICENSE"; \
|
||||
|
@ -14,7 +14,7 @@ RUN set -eux; \
|
||||
#
|
||||
# install widevine module
|
||||
CHROMIUM_DIR="/usr/lib/chromium"; \
|
||||
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | tail -n 1); \
|
||||
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | sort --version-sort | tail -n 1); \
|
||||
wget -O /tmp/widevine.zip "https://dl.google.com/widevine-cdm/${WIDEVINE_VERSION}-linux-x64.zip"; \
|
||||
mkdir -p "${CHROMIUM_DIR}/WidevineCdm/_platform_specific/linux_x64"; \
|
||||
unzip -p /tmp/widevine.zip LICENSE.txt > "${CHROMIUM_DIR}/WidevineCdm/LICENSE"; \
|
||||
|
@ -3,7 +3,7 @@ FROM $BASE_IMAGE
|
||||
|
||||
# latest working version with EGL: 111.0.5563.146, revert when resolved
|
||||
# 112.0.5615.49 fails: https://github.com/VirtualGL/virtualgl/issues/229
|
||||
ARG SRC_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_112.0.5615.49-1_amd64.deb"
|
||||
ARG SRC_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_111.0.5563.146-1_amd64.deb"
|
||||
|
||||
#
|
||||
# install google chrome
|
||||
|
@ -8,7 +8,7 @@ ARG API_URL="https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edg
|
||||
RUN set -eux; apt-get update; \
|
||||
#
|
||||
# fetch latest release
|
||||
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | tail -1)"; \
|
||||
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | sort --version-sort | tail -1)"; \
|
||||
wget -O /tmp/microsoft-edge.deb "${SRC_URL}"; \
|
||||
apt-get install -y --no-install-recommends openbox /tmp/microsoft-edge.deb; \
|
||||
#
|
||||
|
@ -8,7 +8,7 @@ ARG API_URL="https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edg
|
||||
RUN set -eux; apt-get update; \
|
||||
#
|
||||
# fetch latest release
|
||||
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | tail -1)"; \
|
||||
SRC_URL="${API_URL}$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"]*\).*/\1/p' | sort --version-sort | tail -1)"; \
|
||||
wget -O /tmp/microsoft-edge.deb "${SRC_URL}"; \
|
||||
apt-get install -y --no-install-recommends openbox /tmp/microsoft-edge.deb; \
|
||||
#
|
||||
|
@ -9,7 +9,7 @@ ARG LIBFFMPEG_API_URL="https://api.github.com/repos/nwjs-ffmpeg-prebuilt/nwjs-ff
|
||||
RUN set -eux; apt-get update; \
|
||||
#
|
||||
# fetch latest release
|
||||
VERSION="$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"/]*\).*/\1/p' | tail -1)"; \
|
||||
VERSION="$(wget -O - "${API_URL}" 2>/dev/null | sed -n 's/.*href="\([^"/]*\).*/\1/p' | sort --version-sort | tail -1)"; \
|
||||
wget -O /tmp/opera.deb "${API_URL}${VERSION}/linux/opera-stable_${VERSION}_amd64.deb"; \
|
||||
apt-get install -y --no-install-recommends openbox jq unzip /tmp/opera.deb; \
|
||||
#
|
||||
|
@ -21,7 +21,7 @@ RUN set -eux; apt-get update; \
|
||||
chmod 4755 /usr/lib/chromium/chrome-sandbox; \
|
||||
#
|
||||
# install widevine module
|
||||
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | tail -n 1); \
|
||||
WIDEVINE_VERSION=$(wget --quiet -O - https://dl.google.com/widevine-cdm/versions.txt | sort --version-sort | tail -n 1); \
|
||||
wget -O /tmp/widevine.zip "https://dl.google.com/widevine-cdm/${WIDEVINE_VERSION}-linux-x64.zip"; \
|
||||
unzip -p /tmp/widevine.zip libwidevinecdm.so > /usr/lib/chromium/libwidevinecdm.so; \
|
||||
chmod 644 /usr/lib/chromium/libwidevinecdm.so; \
|
||||
|
37
.github/workflows/ghcr-arm.yml
vendored
37
.github/workflows/ghcr-arm.yml
vendored
@ -13,7 +13,7 @@ env:
|
||||
PLATFORMS: linux/arm64,linux/arm/v7
|
||||
|
||||
jobs:
|
||||
build-base:
|
||||
build-client:
|
||||
runs-on: ubuntu-latest
|
||||
#
|
||||
# do not run on forks
|
||||
@ -23,6 +23,41 @@ jobs:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Set up node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
-
|
||||
name: Build client
|
||||
run: |
|
||||
cd client
|
||||
npm install
|
||||
npm run build
|
||||
-
|
||||
name: Upload client dist
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: client-dist
|
||||
path: client/dist
|
||||
|
||||
build-base:
|
||||
runs-on: ubuntu-latest
|
||||
#
|
||||
# do not run on forks
|
||||
#
|
||||
if: github.repository_owner == 'm1k1o'
|
||||
needs: [ build-client ]
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Download client dist
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: client-dist
|
||||
path: client/dist
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
4
LICENSE
4
LICENSE
@ -186,7 +186,9 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2020 Nurdism <nurdism.io@gmail.com>, 2020-2021 m1k1o
|
||||
Copyright (C) 2020 Nurdism <nurdism.io@gmail.com>
|
||||
Copyright (C) 2020-2023 m1k1o
|
||||
All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -204,7 +204,9 @@
|
||||
|
||||
@Watch('volume', { immediate: true })
|
||||
onVolume(volume: number) {
|
||||
this.$accessor.video.setVolume(volume)
|
||||
if (new URL(location.href).searchParams.has('volume')) {
|
||||
this.$accessor.video.setVolume(volume)
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('hideControls', { immediate: true })
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li v-if="!implicitHosting && (!controlLocked || hosting)">
|
||||
<li v-if="!implicitHosting && hosting">
|
||||
<i
|
||||
:class="[
|
||||
!disabeld && shakeKbd ? 'shake' : '',
|
||||
@ -22,9 +22,9 @@
|
||||
</li>
|
||||
<li class="no-pointer" v-if="implicitHosting">
|
||||
<i
|
||||
:class="[controlLocked ? 'disabled' : '', 'fas', 'fa-mouse-pointer']"
|
||||
:class="['fas', 'fa-mouse-pointer']"
|
||||
v-tooltip="{
|
||||
content: controlLocked ? $t('controls.hasnot') : $t('controls.has'),
|
||||
content: $t('controls.has'),
|
||||
placement: 'top',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
@ -32,7 +32,7 @@
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="implicitHosting || (!implicitHosting && (!controlLocked || hosting))">
|
||||
<li v-if="implicitHosting || (!implicitHosting && hosting)">
|
||||
<label
|
||||
class="switch"
|
||||
v-tooltip="{
|
||||
@ -43,7 +43,7 @@
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
>
|
||||
<input type="checkbox" v-model="locked" :disabled="!hosting || (implicitHosting && controlLocked)" />
|
||||
<input type="checkbox" v-model="locked" :disabled="!hosting || implicitHosting" />
|
||||
<span />
|
||||
</label>
|
||||
</li>
|
||||
@ -258,10 +258,6 @@
|
||||
export default class extends Vue {
|
||||
@Prop(Boolean) readonly shakeKbd!: boolean
|
||||
|
||||
get controlLocked() {
|
||||
return 'control' in this.$accessor.locked && this.$accessor.locked['control'] && !this.$accessor.user.admin
|
||||
}
|
||||
|
||||
get disabeld() {
|
||||
return this.$accessor.remote.hosted
|
||||
}
|
||||
|
@ -1,520 +0,0 @@
|
||||
<template>
|
||||
<div class="files">
|
||||
<div class="files-cwd">
|
||||
<p>{{ cwd }}</p>
|
||||
<i class="fas fa-rotate-right refresh" @click="refresh" />
|
||||
</div>
|
||||
<div class="files-list">
|
||||
<div v-for="item in files" :key="item.name" class="files-list-item">
|
||||
<i :class="fileIcon(item)" />
|
||||
<p class="file-name" :title="item.name">{{ item.name }}</p>
|
||||
<p class="file-size">{{ fileSize(item.size) }}</p>
|
||||
<i v-if="item.type !== 'dir'" class="fas fa-download download" @click="download(item)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="transfer-area">
|
||||
<div class="transfers" v-if="transfers.length > 0">
|
||||
<p v-if="downloads.length > 0" class="transfers-list-header">
|
||||
<span>{{ $t('files.downloads') }}</span>
|
||||
<i class="fas fa-xmark remove-transfer" @click="downloads.forEach((t) => removeTransfer(t))"></i>
|
||||
</p>
|
||||
<div v-for="download in downloads" :key="download.id" class="transfers-list-item">
|
||||
<div class="transfer-info">
|
||||
<i
|
||||
class="fas transfer-status"
|
||||
:class="{
|
||||
'fa-clock': download.status === 'pending',
|
||||
'fa-arrows-rotate': download.status === 'inprogress',
|
||||
'fa-check': download.status === 'completed',
|
||||
'fa-warning': download.status === 'failed',
|
||||
}"
|
||||
></i>
|
||||
<p class="file-name" :title="download.name">{{ download.name }}</p>
|
||||
<p class="file-size">{{ Math.min(100, Math.round((download.progress / download.size) * 100)) }}%</p>
|
||||
<i class="fas fa-xmark remove-transfer" @click="removeTransfer(download)"></i>
|
||||
</div>
|
||||
<div v-if="download.status === 'failed'" class="transfer-error">{{ download.error }}</div>
|
||||
<progress
|
||||
v-else
|
||||
class="transfer-progress"
|
||||
:aria-label="download.name + ' progress'"
|
||||
:value="download.progress"
|
||||
:max="download.size"
|
||||
></progress>
|
||||
</div>
|
||||
<p v-if="uploads.length > 0" class="transfers-list-header">
|
||||
<span>{{ $t('files.uploads') }}</span>
|
||||
<i class="fas fa-xmark remove-transfer" @click="uploads.forEach((t) => removeTransfer(t))"></i>
|
||||
</p>
|
||||
<div v-for="upload in uploads" :key="upload.id" class="transfers-list-item">
|
||||
<div class="transfer-info">
|
||||
<i
|
||||
class="fas transfer-status"
|
||||
:title="upload.status"
|
||||
:class="{
|
||||
'fa-clock': upload.status === 'pending',
|
||||
'fa-arrows-rotate': upload.status === 'inprogress',
|
||||
'fa-check': upload.status === 'completed',
|
||||
'fa-warning': upload.status === 'failed',
|
||||
}"
|
||||
></i>
|
||||
<p class="file-name" :title="upload.name">{{ upload.name }}</p>
|
||||
<p class="file-size">{{ Math.min(100, Math.round((upload.progress / upload.size) * 100)) }}%</p>
|
||||
<i class="fas fa-xmark remove-transfer" @click="removeTransfer(upload)"></i>
|
||||
</div>
|
||||
<div v-if="upload.status === 'failed'" class="transfer-error">{{ upload.error }}</div>
|
||||
<progress
|
||||
v-else
|
||||
class="transfer-progress"
|
||||
:aria-label="upload.name + ' progress'"
|
||||
:value="upload.progress"
|
||||
:max="upload.size"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="upload-area"
|
||||
:class="{ 'upload-area-drag': uploadAreaDrag }"
|
||||
@dragover.prevent="uploadAreaDrag = true"
|
||||
@dragleave.prevent="uploadAreaDrag = false"
|
||||
@drop.prevent="(e) => upload(e.dataTransfer)"
|
||||
@click="openFileBrowser"
|
||||
>
|
||||
<i class="fas fa-file-arrow-up" />
|
||||
<p>{{ $t('files.upload_here') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.files {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
|
||||
.files-cwd {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 10px 10px 0px 10px;
|
||||
padding: 0.5em;
|
||||
font-weight: 600;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
margin: 10px 10px 10px 10px;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $background-tertiary transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $background-tertiary;
|
||||
border: 2px solid $background-primary;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $background-floating;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list-item {
|
||||
padding: 0.5em;
|
||||
border-bottom: 2px solid rgba($color: #fff, $alpha: 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.transfers-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid rgba($color: #fff, $alpha: 0.1);
|
||||
}
|
||||
|
||||
.file-icon,
|
||||
.transfer-status {
|
||||
width: 14px;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.transfer-error {
|
||||
border: 1px solid $style-error;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.files-list-item:last-child {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
margin-left: auto;
|
||||
margin-right: 0.5em;
|
||||
color: rgba($color: #fff, $alpha: 0.4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refresh:hover,
|
||||
.download:hover,
|
||||
.remove-transfer:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transfer-area {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.transfers {
|
||||
margin: 10px 10px 10px 10px;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $background-tertiary transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $background-tertiary;
|
||||
border: 2px solid $background-primary;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $background-floating;
|
||||
}
|
||||
}
|
||||
|
||||
.transfers > p {
|
||||
padding: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.transfer-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.transfer-progress {
|
||||
margin: 0px 10px 10px 10px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
margin: 10px 10px 10px 10px;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-area-drag,
|
||||
.upload-area:hover {
|
||||
background-color: rgba($color: #fff, $alpha: 0.1);
|
||||
}
|
||||
|
||||
.upload-area > i {
|
||||
font-size: 4em;
|
||||
margin: 10px 10px 10px 10px;
|
||||
}
|
||||
|
||||
.upload-area > p {
|
||||
margin: 0px 10px 10px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
|
||||
import Markdown from './markdown'
|
||||
import Content from './context.vue'
|
||||
import { FileTransfer, FileListItem } from '~/neko/types'
|
||||
|
||||
@Component({
|
||||
name: 'neko-files',
|
||||
components: {
|
||||
'neko-markdown': Markdown,
|
||||
'neko-context': Content,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public uploadAreaDrag: boolean = false
|
||||
|
||||
get cwd() {
|
||||
return this.$accessor.files.cwd
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.$accessor.files.files
|
||||
}
|
||||
|
||||
get transfers() {
|
||||
return this.$accessor.files.transfers
|
||||
}
|
||||
|
||||
get downloads() {
|
||||
return this.$accessor.files.transfers.filter((t) => t.direction === 'download')
|
||||
}
|
||||
|
||||
get uploads() {
|
||||
return this.$accessor.files.transfers.filter((t) => t.direction === 'upload')
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.$accessor.files.refresh()
|
||||
}
|
||||
|
||||
download(item: FileListItem) {
|
||||
if (this.downloads.map((t) => t.name).includes(item.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url =
|
||||
'/file?pwd=' + encodeURIComponent(this.$accessor.password) + '&filename=' + encodeURIComponent(item.name)
|
||||
const abortController = new AbortController()
|
||||
|
||||
let transfer: FileTransfer = {
|
||||
id: Math.round(Math.random() * 10000),
|
||||
name: item.name,
|
||||
direction: 'download',
|
||||
// this may be smaller than the actual transfer amount, but for large files the
|
||||
// content length is not sent (chunked transfer)
|
||||
size: item.size,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
abortController: abortController,
|
||||
}
|
||||
|
||||
this.$http
|
||||
.get(url, {
|
||||
responseType: 'blob',
|
||||
signal: abortController.signal,
|
||||
withCredentials: false,
|
||||
onDownloadProgress: (x) => {
|
||||
transfer.progress = x.loaded
|
||||
|
||||
if (x.total && transfer.size !== x.total) {
|
||||
transfer.size = x.total
|
||||
}
|
||||
if (transfer.progress === transfer.size) {
|
||||
transfer.status = 'completed'
|
||||
} else if (transfer.status !== 'inprogress') {
|
||||
transfer.status = 'inprogress'
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', item.name)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
transfer.progress = transfer.size
|
||||
transfer.status = 'completed'
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$log.error(error)
|
||||
|
||||
transfer.status = 'failed'
|
||||
transfer.error = error.message
|
||||
})
|
||||
|
||||
this.$accessor.files.addTransfer(transfer)
|
||||
}
|
||||
|
||||
upload(dt: DataTransfer) {
|
||||
const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password)
|
||||
this.uploadAreaDrag = false
|
||||
|
||||
for (const file of dt.files) {
|
||||
const abortController = new AbortController()
|
||||
|
||||
const formdata = new FormData()
|
||||
formdata.append('files', file, file.name)
|
||||
|
||||
let transfer: FileTransfer = {
|
||||
id: Math.round(Math.random() * 10000),
|
||||
name: file.name,
|
||||
direction: 'upload',
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
abortController: abortController,
|
||||
}
|
||||
|
||||
this.$http
|
||||
.post(url, formdata, {
|
||||
signal: abortController.signal,
|
||||
withCredentials: false,
|
||||
onUploadProgress: (x: any) => {
|
||||
transfer.progress = x.loaded
|
||||
|
||||
if (transfer.size !== x.total) {
|
||||
transfer.size = x.total
|
||||
}
|
||||
if (transfer.progress === transfer.size) {
|
||||
transfer.status = 'completed'
|
||||
} else if (transfer.status !== 'inprogress') {
|
||||
transfer.status = 'inprogress'
|
||||
}
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$log.error(error)
|
||||
|
||||
transfer.status = 'failed'
|
||||
transfer.error = error.message
|
||||
})
|
||||
|
||||
this.$accessor.files.addTransfer(transfer)
|
||||
}
|
||||
}
|
||||
|
||||
openFileBrowser() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.setAttribute('multiple', 'true')
|
||||
input.onchange = (e: Event) => {
|
||||
if (e === null) return
|
||||
|
||||
const dt = new DataTransfer()
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files === null) return
|
||||
|
||||
for (const f of target.files) {
|
||||
dt.items.add(f)
|
||||
}
|
||||
|
||||
this.upload(dt)
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
removeTransfer(transfer: FileTransfer) {
|
||||
if (transfer.status !== 'completed') {
|
||||
transfer.abortController?.abort()
|
||||
}
|
||||
this.$accessor.files.removeTransfer(transfer)
|
||||
}
|
||||
|
||||
fileIcon(file: FileListItem) {
|
||||
let className = 'file-icon fas '
|
||||
// if is directory
|
||||
if (file.type === 'dir') {
|
||||
className += 'fa-folder'
|
||||
return className
|
||||
}
|
||||
// try to get file extension
|
||||
const ext = file.name.split('.').pop()
|
||||
if (ext === undefined) {
|
||||
className += 'fa-file'
|
||||
return className
|
||||
}
|
||||
// try to find icon
|
||||
switch (ext.toLowerCase()) {
|
||||
case 'txt':
|
||||
case 'md':
|
||||
className += 'fa-file-text'
|
||||
break
|
||||
case 'pdf':
|
||||
className += 'fa-file-pdf'
|
||||
break
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'gz':
|
||||
className += 'fa-archive'
|
||||
break
|
||||
case 'aac':
|
||||
case 'flac':
|
||||
case 'midi':
|
||||
case 'mp3':
|
||||
case 'ogg':
|
||||
case 'wav':
|
||||
className += 'fa-music'
|
||||
break
|
||||
case 'avi':
|
||||
case 'mkv':
|
||||
case 'mov':
|
||||
case 'mpeg':
|
||||
case 'mp4':
|
||||
case 'webm':
|
||||
className += 'fa-film'
|
||||
break
|
||||
case 'bmp':
|
||||
case 'gif':
|
||||
case 'jpeg':
|
||||
case 'jpg':
|
||||
case 'png':
|
||||
case 'svg':
|
||||
case 'tiff':
|
||||
case 'webp':
|
||||
className += 'fa-image'
|
||||
break
|
||||
default:
|
||||
className += 'fa-file'
|
||||
}
|
||||
return className
|
||||
}
|
||||
|
||||
fileSize(size: number) {
|
||||
if (size < 1024) {
|
||||
return size + ' B'
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return Math.round(size / 1024) + ' KB'
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return Math.round(size / (1024 * 1024)) + ' MB'
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024 * 1024) {
|
||||
return Math.round(size / (1024 * 1024 * 1024)) + ' GB'
|
||||
}
|
||||
return Math.round(size / (1024 * 1024 * 1024 * 1024)) + ' TB'
|
||||
}
|
||||
}
|
||||
</script>
|
@ -5,45 +5,6 @@
|
||||
<span><b>n</b>.eko</span>
|
||||
</a>
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<i
|
||||
:class="[{ disabled: !admin }, { locked: isLocked('control') }, 'fas', 'fa-mouse']"
|
||||
@click="toggleLock('control')"
|
||||
v-tooltip="{
|
||||
content: lockedTooltip('control'),
|
||||
placement: 'bottom',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<i
|
||||
:class="[{ disabled: !admin }, { locked: isLocked('login') }, locked ? 'fa-lock' : 'fa-lock-open', 'fas']"
|
||||
@click="toggleLock('login')"
|
||||
v-tooltip="{
|
||||
content: lockedTooltip('login'),
|
||||
placement: 'bottom',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="fileTransfer">
|
||||
<i
|
||||
:class="[{ disabled: !admin }, { locked: isLocked('file_transfer') }, 'fas', 'fa-file']"
|
||||
@click="toggleLock('file_transfer')"
|
||||
v-tooltip="{
|
||||
content: lockedTooltip('file_transfer'),
|
||||
placement: 'bottom',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span v-if="showBadge" class="badge">•</span>
|
||||
<i class="fas fa-bars toggle" @click="toggleMenu" />
|
||||
@ -162,14 +123,6 @@
|
||||
|
||||
@Component({ name: 'neko-settings' })
|
||||
export default class extends Vue {
|
||||
get admin() {
|
||||
return this.$accessor.user.admin
|
||||
}
|
||||
|
||||
get locked() {
|
||||
return this.$accessor.locked
|
||||
}
|
||||
|
||||
get side() {
|
||||
return this.$accessor.client.side
|
||||
}
|
||||
@ -182,30 +135,10 @@
|
||||
return !this.side && this.readTexts != this.texts
|
||||
}
|
||||
|
||||
get fileTransfer() {
|
||||
return this.$accessor.remote.fileTransfer
|
||||
}
|
||||
|
||||
toggleLock(resource: AdminLockResource) {
|
||||
this.$accessor.toggleLock(resource)
|
||||
}
|
||||
|
||||
isLocked(resource: AdminLockResource): boolean {
|
||||
return this.$accessor.isLocked(resource)
|
||||
}
|
||||
|
||||
readTexts: number = 0
|
||||
toggleMenu() {
|
||||
this.$accessor.client.toggleSide()
|
||||
this.readTexts = this.texts
|
||||
}
|
||||
|
||||
lockedTooltip(resource: AdminLockResource) {
|
||||
if (this.admin) {
|
||||
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `unlock` : `lock`))
|
||||
}
|
||||
|
||||
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `locked` : `unlocked`))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -76,5 +76,20 @@
|
||||
about() {
|
||||
this.$accessor.client.toggleAbout()
|
||||
}
|
||||
|
||||
mounted() {
|
||||
const default_lang = new URL(location.href).searchParams.get('lang')
|
||||
if (default_lang && this.langs.includes(default_lang)) {
|
||||
this.$i18n.locale = default_lang
|
||||
}
|
||||
const show_side = new URL(location.href).searchParams.get('show_side')
|
||||
if (show_side !== null) {
|
||||
this.$accessor.client.setSide(show_side === '1')
|
||||
}
|
||||
const mute_chat = new URL(location.href).searchParams.get('mute_chat')
|
||||
if (mute_chat !== null) {
|
||||
this.$accessor.settings.setSound(mute_chat !== '1')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -6,10 +6,6 @@
|
||||
<i class="fas fa-comment-alt" />
|
||||
<span>{{ $t('side.chat') }}</span>
|
||||
</li>
|
||||
<li v-if="filetransferAllowed" :class="{ active: tab === 'files' }" @click.stop.prevent="change('files')">
|
||||
<i class="fas fa-file" />
|
||||
<span>{{ $t('side.files') }}</span>
|
||||
</li>
|
||||
<li :class="{ active: tab === 'settings' }" @click.stop.prevent="change('settings')">
|
||||
<i class="fas fa-sliders-h" />
|
||||
<span>{{ $t('side.settings') }}</span>
|
||||
@ -79,47 +75,23 @@
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Watch } from 'vue-property-decorator'
|
||||
import { Vue, Component } from 'vue-property-decorator'
|
||||
|
||||
import Settings from '~/components/settings.vue'
|
||||
import Chat from '~/components/chat.vue'
|
||||
import Files from '~/components/files.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko',
|
||||
components: {
|
||||
'neko-settings': Settings,
|
||||
'neko-chat': Chat,
|
||||
'neko-files': Files,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
get filetransferAllowed() {
|
||||
return (
|
||||
this.$accessor.remote.fileTransfer && (this.$accessor.user.admin || !this.$accessor.isLocked('file_transfer'))
|
||||
)
|
||||
}
|
||||
|
||||
get tab() {
|
||||
return this.$accessor.client.tab
|
||||
}
|
||||
|
||||
@Watch('tab', { immediate: true })
|
||||
@Watch('filetransferAllowed', { immediate: true })
|
||||
onTabChange() {
|
||||
// do not show the files tab if file transfer is disabled
|
||||
if (this.tab === 'files' && !this.filetransferAllowed) {
|
||||
this.change('chat')
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('filetransferAllowed')
|
||||
onFileTransferAllowedChange() {
|
||||
if (this.filetransferAllowed) {
|
||||
this.$accessor.files.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
change(tab: string) {
|
||||
this.$accessor.client.setTab(tab)
|
||||
}
|
||||
|
@ -13,6 +13,7 @@
|
||||
class="overlay"
|
||||
tabindex="0"
|
||||
data-gramm="false"
|
||||
:style="{ pointerEvents: hosting ? 'auto' : 'none' }"
|
||||
@click.stop.prevent
|
||||
@contextmenu.stop.prevent
|
||||
@wheel.stop.prevent="onWheel"
|
||||
@ -36,7 +37,7 @@
|
||||
<ul v-if="!fullscreen && !hideControls" class="video-menu top">
|
||||
<li><i @click.stop.prevent="requestFullscreen" class="fas fa-expand"></i></li>
|
||||
<li v-if="admin"><i @click.stop.prevent="openResolution" class="fas fa-desktop"></i></li>
|
||||
<li v-if="!implicitHosting" :class="extraControls || 'extra-control'">
|
||||
<li v-if="!controlLocked && !implicitHosting" :class="extraControls || 'extra-control'">
|
||||
<i
|
||||
:class="[hosted && !hosting ? 'disabled' : '', !hosted && !hosting ? 'faded' : '', 'fas', 'fa-keyboard']"
|
||||
@click.stop.prevent="toggleControl"
|
||||
|
@ -52,33 +52,6 @@ export const controls = {
|
||||
unlock: 'Steuerung entsperren',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Steuerung sperren (für Nutzer)',
|
||||
unlock: 'Steuerung entsperren (für Nutzer)',
|
||||
locked: 'Steuerung gesperrt (für Nutzer)',
|
||||
unlocked: 'Steuerung entsperrt (für Nutzer)',
|
||||
notif_locked: 'Steuerung sperren für Nutzer',
|
||||
notif_unlocked: 'Steuerung entsperren für Nutzer',
|
||||
},
|
||||
login: {
|
||||
lock: 'Raum sperren (für Nutzer)',
|
||||
unlock: 'Raum entsperren (für Nutzer)',
|
||||
locked: 'Raum gesperrt (für Nutzer)',
|
||||
unlocked: 'Raum entsperrt (für Nutzer)',
|
||||
notif_locked: 'Raum gesperrt',
|
||||
notif_unlocked: 'Raum entsperrt',
|
||||
},
|
||||
file_transfer: {
|
||||
lock: 'Dateiübertragung sperren (für Nutzer)',
|
||||
unlock: 'Dateiübertragung entsperren (für Nutzer)',
|
||||
locked: 'Dateiübertragung gesperrt (für Nutzer)',
|
||||
unlocked: 'Dateiübertragung entsperrt (für Nutzer)',
|
||||
notif_locked: 'Dateiübertragung gesperrt',
|
||||
notif_unlocked: 'Dateiübertragung entsperrt',
|
||||
},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scroll-Empfindlichkeit',
|
||||
scroll_invert: 'Bildlauf umkehren',
|
||||
|
@ -54,33 +54,6 @@ export const controls = {
|
||||
hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Lock Controls (for users)',
|
||||
unlock: 'Unlock Controls (for users)',
|
||||
locked: 'Controls Locked (for users)',
|
||||
unlocked: 'Controls Unlocked (for users)',
|
||||
notif_locked: 'locked controls for users',
|
||||
notif_unlocked: 'unlocked controls for users',
|
||||
},
|
||||
login: {
|
||||
lock: 'Lock Room (for users)',
|
||||
unlock: 'Unlock Room (for users)',
|
||||
locked: 'Room Locked (for users)',
|
||||
unlocked: 'Room Unlocked (for users)',
|
||||
notif_locked: 'locked the room',
|
||||
notif_unlocked: 'unlocked the room',
|
||||
},
|
||||
file_transfer: {
|
||||
lock: 'Lock File Transfer (for users)',
|
||||
unlock: 'Unlock File Transfer (for users)',
|
||||
locked: 'File Transfer Locked (for users)',
|
||||
unlocked: 'File Transfer Unlocked (for users)',
|
||||
notif_locked: 'locked file transfer',
|
||||
notif_unlocked: 'unlocked file transfer',
|
||||
},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scroll Sensitivity',
|
||||
scroll_invert: 'Invert Scroll',
|
||||
|
@ -57,35 +57,6 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Bloquear sala (para usuarios)',
|
||||
unlock: 'Desbloquear sala (para usuarios)',
|
||||
locked: 'Sala bloqueada (para usuarios)',
|
||||
unlocked: 'Sala desbloqueada (para usuarios)',
|
||||
notif_locked: 'bloqueó la sala',
|
||||
notif_unlocked: 'desbloqueó la sala',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Sensibilidad del Scroll',
|
||||
scroll_invert: 'Invertir Scroll',
|
||||
|
@ -54,34 +54,6 @@ export const controls = {
|
||||
hasnot: 'Sinulle ei ole kontrolleja',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Lukitse kontrollit (käyttäjiltä)',
|
||||
unlock: 'Vapauta kontrollit (käyttäjiltä)',
|
||||
locked: 'Kontrollit lukittu (käyttäjiltä)',
|
||||
unlocked: 'Kontrollit vapautettu (käyttäjiltä)',
|
||||
notif_locked: 'kontrollit on lukittu käyttäjiltä',
|
||||
notif_unlocked: 'kontrollit on vapautettu käyttäjille',
|
||||
},
|
||||
login: {
|
||||
lock: 'Lukitse huone (käyttäjiltä)',
|
||||
unlock: 'Vapauta huone (käyttäjiltä)',
|
||||
locked: 'Huone lukittu (käyttäjiltä)',
|
||||
unlocked: 'Huone vapautettu (käyttäjiltä)',
|
||||
notif_locked: 'lukittu huone',
|
||||
notif_unlocked: 'vapautettu huone',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scrollin herkkyys',
|
||||
scroll_invert: 'Käänteinen Scroll',
|
||||
|
@ -57,35 +57,6 @@ export const controls = {
|
||||
// hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Vérouiller la salle (pour les utilisateurs)',
|
||||
unlock: 'Dévérouiller la salle (pour les utilisateurs)',
|
||||
locked: 'Salle vérouillée (pour les utilisateurs)',
|
||||
unlocked: 'Salle dévérouillée (pour les utilisateurs)',
|
||||
notif_locked: 'a vérouillé la salle',
|
||||
notif_unlocked: 'a dévérouillé la salle',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Sensibilité de défilement (scroll)',
|
||||
scroll_invert: 'Inverser le défilement (scroll)',
|
||||
|
@ -9,6 +9,7 @@ import * as ko from './ko-kr'
|
||||
import * as fi from './fi-fi'
|
||||
import * as ru from './ru-ru'
|
||||
import * as cn from './zh-cn'
|
||||
import * as tw from './zh-tw'
|
||||
|
||||
export const messages = {
|
||||
en,
|
||||
@ -22,4 +23,5 @@ export const messages = {
|
||||
fi,
|
||||
ru,
|
||||
cn,
|
||||
tw,
|
||||
}
|
||||
|
@ -52,34 +52,6 @@ export const controls = {
|
||||
unlock: '조작 잠금 해제하기',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: '조작 잠그기 (사용자)',
|
||||
unlock: '조작 잠금 해제하기 (사용자)',
|
||||
locked: '조작이 잠겼습니다 (사용자)',
|
||||
unlocked: '조작 잠금이 해제됐습니다 (사용자)',
|
||||
notif_locked: '사용자의 조작을 잠궜습니다',
|
||||
notif_unlocked: '사용자의 조작 잠금을 해제했습니다',
|
||||
},
|
||||
login: {
|
||||
lock: '방 잠그기 (사용자)',
|
||||
unlock: '방 잠금 해제하기 (사용자)',
|
||||
locked: '방이 잠겼습니다 (사용자)',
|
||||
unlocked: '방 잠금이 해제됐습니다 (사용자)',
|
||||
notif_locked: '방이 잠겼습니다',
|
||||
notif_unlocked: '방 잠금이 해제됐습니다',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: '스크롤 감도',
|
||||
scroll_invert: '스크롤 반전',
|
||||
|
@ -57,35 +57,6 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Lås rommet (for brukere)',
|
||||
unlock: 'Lås opp rommet (for brukere)',
|
||||
locked: 'Rom låst (for brukere)',
|
||||
unlocked: 'Rom opplåst (for brukere)',
|
||||
notif_locked: 'låste rommet',
|
||||
notif_unlocked: 'låste opp rommet',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Rullingssensitivitet',
|
||||
scroll_invert: 'Inverter rulling',
|
||||
|
@ -54,34 +54,6 @@ export const controls = {
|
||||
hasnot: 'Вы не управляете',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Закрепить управление (для пользователей)',
|
||||
unlock: 'Открепить управление (для пользователей)',
|
||||
locked: 'Управление закреплено (для пользователей)',
|
||||
unlocked: 'Управление откреплено (для пользователей)',
|
||||
notif_locked: 'закреплено управление для пользователей',
|
||||
notif_unlocked: 'откреплено управление для пользователей',
|
||||
},
|
||||
login: {
|
||||
lock: 'Закрыть комнату (для пользователей)',
|
||||
unlock: 'Открыть комнату (для пользователей)',
|
||||
locked: 'Комната закрыта (для пользователей)',
|
||||
unlocked: 'Комната открыта (для пользователей)',
|
||||
notif_locked: 'комната закрыта',
|
||||
notif_unlocked: 'комната открыта',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Чувствительность прокрутки',
|
||||
scroll_invert: 'Инвертировать прокрутку',
|
||||
|
@ -57,34 +57,6 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Zakázať ovládanie (pre používateľov)',
|
||||
unlock: 'Povoliť ovládanie (pre používateľov)',
|
||||
locked: 'Ovládanie je zakázané (pre používateľov)',
|
||||
unlocked: 'Ovládanie je povolené (pre používateľov)',
|
||||
notif_locked: 'zakázal/a ovládanie pre používateľov',
|
||||
notif_unlocked: 'povolil/a ovládanie pre používateľov',
|
||||
},
|
||||
login: {
|
||||
lock: 'Zamknúť miestnosť (pre používateľov)',
|
||||
unlock: 'Odomknúť miestnosť (pre používateľov)',
|
||||
locked: 'Miestnosť je zamknutá (pre používateľov)',
|
||||
unlocked: 'Miestnosť odomknutá (pre používateľov)',
|
||||
notif_locked: 'miestnosť bola zamknutá',
|
||||
notif_unlocked: 'miestnosť bola odomknutá',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Citlivosť kolieska myši',
|
||||
scroll_invert: 'Invertovať koliesko myši',
|
||||
|
@ -57,35 +57,6 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Lås rum (för användare)',
|
||||
unlock: 'Lås upp rummet (för användare)',
|
||||
locked: 'Rum låst (för användare)',
|
||||
unlocked: 'Rum upplåst (för användare)',
|
||||
notif_locked: 'låste rummet',
|
||||
notif_unlocked: 'låste upp rummet',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scrollkänslighet',
|
||||
scroll_invert: 'Vänd Scrollen',
|
||||
|
@ -54,34 +54,6 @@ export const controls = {
|
||||
hasnot: '你没有控制',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: '对所有用户进行锁定控制',
|
||||
unlock: '对所有用户进行解锁控制',
|
||||
locked: '锁定的控制装置',
|
||||
unlocked: '解锁的控制装置',
|
||||
notif_locked: '为用户锁定控制',
|
||||
notif_unlocked: '为用户解锁控制',
|
||||
},
|
||||
login: {
|
||||
lock: '所有用户的锁定室',
|
||||
unlock: '所有用户的解锁室',
|
||||
locked: '为所有用户锁定的房间',
|
||||
unlocked: '为所有用户解锁的房间',
|
||||
notif_locked: '锁上房间',
|
||||
notif_unlocked: '解锁房间',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: '滚动敏感度',
|
||||
scroll_invert: '反转滚动敏感度',
|
||||
|
100
client/src/locale/zh-tw.ts
Normal file
100
client/src/locale/zh-tw.ts
Normal file
@ -0,0 +1,100 @@
|
||||
export const logout = '登出'
|
||||
export const unsupported = '您的網頁瀏覽器不支援 WebRTC'
|
||||
export const admin_loggedin = '您已經以管理員身份登入'
|
||||
export const you = '您'
|
||||
export const somebody = '某人'
|
||||
export const send_a_message = '傳送訊息'
|
||||
|
||||
export const side = {
|
||||
chat: '聊天',
|
||||
files: '檔案',
|
||||
settings: '設定',
|
||||
}
|
||||
|
||||
export const connect = {
|
||||
login_title: '請登入',
|
||||
invitation_title: '您已被邀請進入此房間',
|
||||
displayname: '輸入您的顯示名稱',
|
||||
password: '密碼',
|
||||
connect: '連線',
|
||||
error: '登入錯誤',
|
||||
empty_displayname: '顯示名稱不能為空。',
|
||||
}
|
||||
|
||||
export const context = {
|
||||
ignore: '忽略',
|
||||
unignore: '取消忽略',
|
||||
mute: '靜音',
|
||||
unmute: '解除靜音',
|
||||
release: '強制釋放控制',
|
||||
take: '強制接管控制',
|
||||
give: '移交控制',
|
||||
kick: '踢出',
|
||||
ban: '封鎖 IP',
|
||||
confirm: {
|
||||
kick_title: '踢出 {name}?',
|
||||
kick_text: '您確定要踢出 {name} 嗎?',
|
||||
ban_title: '封鎖 {name}?',
|
||||
ban_text: '您是否要封鎖 {name}?封鎖後需要重新啟動伺服器才能取消封鎖。',
|
||||
mute_title: '靜音 {name}?',
|
||||
mute_text: '您確定要將 {name} 設為靜音嗎?',
|
||||
unmute_title: '解除靜音 {name}?',
|
||||
unmute_text: '您是否要解除 {name} 的靜音?',
|
||||
button_yes: '是',
|
||||
button_cancel: '取消',
|
||||
},
|
||||
}
|
||||
|
||||
export const controls = {
|
||||
release: '釋放控制',
|
||||
request: '請求控制',
|
||||
lock: '鎖定控制',
|
||||
unlock: '解鎖控制',
|
||||
has: '您擁有控制權',
|
||||
hasnot: '您沒有控制權',
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: '滾動靈敏度',
|
||||
scroll_invert: '反向滾動',
|
||||
autoplay: '自動播放影片',
|
||||
ignore_emotes: '忽略表情符號',
|
||||
chat_sound: '播放聊天音效',
|
||||
keyboard_layout: '鍵盤配置',
|
||||
broadcast_title: '直播',
|
||||
}
|
||||
|
||||
export const connection = {
|
||||
logged_out: '您已登出。',
|
||||
reconnecting: '正在重新連線…',
|
||||
connected: '已連線',
|
||||
disconnected: '已斷線',
|
||||
kicked: '您已被移出此房間。',
|
||||
button_confirm: '確定',
|
||||
}
|
||||
|
||||
export const notifications = {
|
||||
connected: '{name} 已連線',
|
||||
disconnected: '{name} 已斷線',
|
||||
controls_taken: '{name} 接管了控制權',
|
||||
controls_taken_force: '強制接管控制權',
|
||||
controls_taken_steal: '從 {name} 奪取了控制權',
|
||||
controls_released: '{name} 釋放了控制權',
|
||||
controls_released_force: '強制釋放控制權',
|
||||
controls_released_steal: '從 {name} 強制釋放控制權',
|
||||
controls_given: '將控制權交給 {name}',
|
||||
controls_has: '{name} 擁有控制權',
|
||||
controls_has_alt: '但我已通知對方您有意接管',
|
||||
controls_requesting: '{name} 正在請求控制權',
|
||||
resolution: '將解析度改為 {width}x{height}@{rate}',
|
||||
banned: '已封鎖 {name}',
|
||||
kicked: '已踢出 {name}',
|
||||
muted: '已將 {name} 靜音',
|
||||
unmuted: '已解除 {name} 的靜音',
|
||||
}
|
||||
|
||||
export const files = {
|
||||
downloads: '下載',
|
||||
uploads: '上傳',
|
||||
upload_here: '點選或拖曳檔案至此上傳',
|
||||
}
|
@ -38,10 +38,6 @@ export const EVENT = {
|
||||
MESSAGE: 'chat/message',
|
||||
EMOTE: 'chat/emote',
|
||||
},
|
||||
FILETRANSFER: {
|
||||
LIST: 'filetransfer/list',
|
||||
REFRESH: 'filetransfer/refresh',
|
||||
},
|
||||
SCREEN: {
|
||||
CONFIGURATIONS: 'screen/configurations',
|
||||
RESOLUTION: 'screen/resolution',
|
||||
@ -55,8 +51,6 @@ export const EVENT = {
|
||||
ADMIN: {
|
||||
BAN: 'admin/ban',
|
||||
KICK: 'admin/kick',
|
||||
LOCK: 'admin/lock',
|
||||
UNLOCK: 'admin/unlock',
|
||||
MUTE: 'admin/mute',
|
||||
UNMUTE: 'admin/unmute',
|
||||
CONTROL: 'admin/control',
|
||||
@ -73,7 +67,6 @@ export type WebSocketEvents =
|
||||
| MemberEvents
|
||||
| SignalEvents
|
||||
| ChatEvents
|
||||
| FileTransferEvents
|
||||
| ScreenEvents
|
||||
| BroadcastEvents
|
||||
| AdminEvents
|
||||
@ -97,8 +90,6 @@ export type SignalEvents =
|
||||
|
||||
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
|
||||
|
||||
export type FileTransferEvents = typeof EVENT.FILETRANSFER.LIST | typeof EVENT.FILETRANSFER.REFRESH
|
||||
|
||||
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
|
||||
|
||||
export type BroadcastEvents =
|
||||
@ -109,8 +100,6 @@ export type BroadcastEvents =
|
||||
export type AdminEvents =
|
||||
| typeof EVENT.ADMIN.BAN
|
||||
| typeof EVENT.ADMIN.KICK
|
||||
| typeof EVENT.ADMIN.LOCK
|
||||
| typeof EVENT.ADMIN.UNLOCK
|
||||
| typeof EVENT.ADMIN.MUTE
|
||||
| typeof EVENT.ADMIN.UNMUTE
|
||||
| typeof EVENT.ADMIN.CONTROL
|
||||
|
@ -22,7 +22,6 @@ import {
|
||||
AdminLockMessage,
|
||||
SystemInitPayload,
|
||||
AdminLockResource,
|
||||
FileTransferListPayload,
|
||||
} from './messages'
|
||||
|
||||
interface NekoEvents extends BaseEvents {}
|
||||
@ -134,17 +133,9 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
/////////////////////////////
|
||||
// System Events
|
||||
/////////////////////////////
|
||||
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, locks, file_transfer }: SystemInitPayload) {
|
||||
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, file_transfer }: SystemInitPayload) {
|
||||
this.$accessor.remote.setImplicitHosting(implicit_hosting)
|
||||
this.$accessor.remote.setFileTransfer(file_transfer)
|
||||
|
||||
for (const resource in locks) {
|
||||
this[EVENT.ADMIN.LOCK]({
|
||||
event: EVENT.ADMIN.LOCK,
|
||||
resource: resource as AdminLockResource,
|
||||
id: locks[resource],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
protected [EVENT.SYSTEM.DISCONNECT]({ message }: SystemMessagePayload) {
|
||||
@ -353,14 +344,6 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
this.$accessor.chat.newEmote({ type: emote })
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// File Transfer Events
|
||||
/////////////////////////////
|
||||
protected [EVENT.FILETRANSFER.LIST]({ cwd, files }: FileTransferListPayload) {
|
||||
this.$accessor.files.setCwd(cwd)
|
||||
this.$accessor.files.setFileList(files)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Screen Events
|
||||
/////////////////////////////
|
||||
@ -486,28 +469,6 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
})
|
||||
}
|
||||
|
||||
protected [EVENT.ADMIN.LOCK]({ id, resource }: AdminLockMessage) {
|
||||
this.$accessor.setLocked(resource)
|
||||
|
||||
this.$accessor.chat.newMessage({
|
||||
id,
|
||||
content: this.$vue.$t(`locks.${resource}.notif_locked`) as string,
|
||||
type: 'event',
|
||||
created: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
protected [EVENT.ADMIN.UNLOCK]({ id, resource }: AdminLockMessage) {
|
||||
this.$accessor.setUnlocked(resource)
|
||||
|
||||
this.$accessor.chat.newMessage({
|
||||
id,
|
||||
content: this.$vue.$t(`locks.${resource}.notif_unlocked`) as string,
|
||||
type: 'event',
|
||||
created: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
protected [EVENT.ADMIN.CONTROL]({ id, target }: AdminTargetPayload) {
|
||||
this.$accessor.remote.setHost(id)
|
||||
this.$accessor.remote.changeKeyboard()
|
||||
|
@ -8,9 +8,8 @@ import {
|
||||
ChatEvents,
|
||||
ScreenEvents,
|
||||
AdminEvents,
|
||||
FileTransferEvents,
|
||||
} from './events'
|
||||
import { FileListItem, Member, ScreenConfigurations, ScreenResolution } from './types'
|
||||
import { Member, ScreenConfigurations, ScreenResolution } from './types'
|
||||
|
||||
export type WebSocketMessages =
|
||||
| WebSocketMessage
|
||||
@ -194,18 +193,6 @@ export interface EmojiSendPayload {
|
||||
emote: string
|
||||
}
|
||||
|
||||
/*
|
||||
FILE TRANSFER PAYLOADS
|
||||
*/
|
||||
export interface FileTransferListMessage extends WebSocketMessage, FileTransferListPayload {
|
||||
event: FileTransferEvents
|
||||
}
|
||||
|
||||
export interface FileTransferListPayload {
|
||||
cwd: string
|
||||
files: FileListItem[]
|
||||
}
|
||||
|
||||
/*
|
||||
SCREEN PAYLOADS
|
||||
*/
|
||||
|
@ -22,20 +22,3 @@ export interface ScreenResolution {
|
||||
height: number
|
||||
rate: number
|
||||
}
|
||||
|
||||
export interface FileListItem {
|
||||
name: string
|
||||
type: 'file' | 'dir'
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface FileTransfer {
|
||||
id: number
|
||||
name: string
|
||||
direction: 'upload' | 'download'
|
||||
size: number
|
||||
progress: number
|
||||
status: 'pending' | 'inprogress' | 'completed' | 'failed'
|
||||
error?: string
|
||||
abortController?: AbortController
|
||||
}
|
||||
|
@ -6,5 +6,6 @@ Vue.use(VueI18n)
|
||||
|
||||
export const i18n = new VueI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
|
@ -27,6 +27,10 @@ export const mutations = mutationTree(state, {
|
||||
state.side = !state.side
|
||||
set('side', state.side)
|
||||
},
|
||||
setSide(state, side: boolean) {
|
||||
state.side = side
|
||||
set('side', side)
|
||||
},
|
||||
})
|
||||
|
||||
export const actions = actionTree({ state, getters, mutations }, {})
|
||||
|
@ -1,72 +0,0 @@
|
||||
import { actionTree, getterTree, mutationTree } from 'typed-vuex'
|
||||
import { FileListItem, FileTransfer } from '~/neko/types'
|
||||
import { EVENT } from '~/neko/events'
|
||||
import { accessor } from '~/store'
|
||||
|
||||
export const state = () => ({
|
||||
cwd: '',
|
||||
files: [] as FileListItem[],
|
||||
transfers: [] as FileTransfer[],
|
||||
})
|
||||
|
||||
export const getters = getterTree(state, {
|
||||
//
|
||||
})
|
||||
|
||||
export const mutations = mutationTree(state, {
|
||||
_setCwd(state, cwd: string) {
|
||||
state.cwd = cwd
|
||||
},
|
||||
|
||||
_setFileList(state, files: FileListItem[]) {
|
||||
state.files = files
|
||||
},
|
||||
|
||||
_addTransfer(state, transfer: FileTransfer) {
|
||||
state.transfers = [...state.transfers, transfer]
|
||||
},
|
||||
|
||||
_removeTransfer(state, transfer: FileTransfer) {
|
||||
state.transfers = state.transfers.filter((t) => t.id !== transfer.id)
|
||||
},
|
||||
})
|
||||
|
||||
export const actions = actionTree(
|
||||
{ state, getters, mutations },
|
||||
{
|
||||
setCwd(store, cwd: string) {
|
||||
accessor.files._setCwd(cwd)
|
||||
},
|
||||
|
||||
setFileList(store, files: FileListItem[]) {
|
||||
accessor.files._setFileList(files)
|
||||
},
|
||||
|
||||
addTransfer(store, transfer: FileTransfer) {
|
||||
if (transfer.status !== 'pending') {
|
||||
return
|
||||
}
|
||||
accessor.files._addTransfer(transfer)
|
||||
},
|
||||
|
||||
removeTransfer(store, transfer: FileTransfer) {
|
||||
accessor.files._removeTransfer(transfer)
|
||||
},
|
||||
|
||||
cancelAllTransfers() {
|
||||
for (const t of accessor.files.transfers) {
|
||||
if (t.status !== 'completed') {
|
||||
t.abortController?.abort()
|
||||
}
|
||||
accessor.files.removeTransfer(t)
|
||||
}
|
||||
},
|
||||
|
||||
refresh() {
|
||||
if (!accessor.connected) {
|
||||
return
|
||||
}
|
||||
$client.sendMessage(EVENT.FILETRANSFER.REFRESH)
|
||||
},
|
||||
},
|
||||
)
|
@ -7,7 +7,6 @@ import { get, set } from '~/utils/localstorage'
|
||||
|
||||
import * as video from './video'
|
||||
import * as chat from './chat'
|
||||
import * as files from './files'
|
||||
import * as remote from './remote'
|
||||
import * as user from './user'
|
||||
import * as settings from './settings'
|
||||
@ -20,7 +19,6 @@ export const state = () => ({
|
||||
active: false,
|
||||
connecting: false,
|
||||
connected: false,
|
||||
locked: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
export const mutations = mutationTree(state, {
|
||||
@ -33,14 +31,6 @@ export const mutations = mutationTree(state, {
|
||||
state.password = password
|
||||
},
|
||||
|
||||
setLocked(state, resource: string) {
|
||||
Vue.set(state.locked, resource, true)
|
||||
},
|
||||
|
||||
setUnlocked(state, resource: string) {
|
||||
Vue.set(state.locked, resource, false)
|
||||
},
|
||||
|
||||
setConnnecting(state) {
|
||||
state.connected = false
|
||||
state.connecting = true
|
||||
@ -56,9 +46,7 @@ export const mutations = mutationTree(state, {
|
||||
},
|
||||
})
|
||||
|
||||
export const getters = getterTree(state, {
|
||||
isLocked: (state) => (resource: AdminLockResource) => resource in state.locked && state.locked[resource],
|
||||
})
|
||||
export const getters = getterTree(state, {})
|
||||
|
||||
export const actions = actionTree(
|
||||
{ state, getters, mutations },
|
||||
@ -68,30 +56,6 @@ export const actions = actionTree(
|
||||
accessor.settings.initialise()
|
||||
},
|
||||
|
||||
lock(_, resource: AdminLockResource) {
|
||||
if (!accessor.connected || !accessor.user.admin) {
|
||||
return
|
||||
}
|
||||
|
||||
$client.sendMessage(EVENT.ADMIN.LOCK, { resource })
|
||||
},
|
||||
|
||||
unlock(_, resource: AdminLockResource) {
|
||||
if (!accessor.connected || !accessor.user.admin) {
|
||||
return
|
||||
}
|
||||
|
||||
$client.sendMessage(EVENT.ADMIN.UNLOCK, { resource })
|
||||
},
|
||||
|
||||
toggleLock(_, resource: AdminLockResource) {
|
||||
if (accessor.isLocked(resource)) {
|
||||
accessor.unlock(resource)
|
||||
} else {
|
||||
accessor.lock(resource)
|
||||
}
|
||||
},
|
||||
|
||||
login(store, { displayname, password }: { displayname: string; password: string }) {
|
||||
accessor.setLogin({ displayname, password })
|
||||
$client.login(password, displayname)
|
||||
@ -111,7 +75,7 @@ export const storePattern = {
|
||||
mutations,
|
||||
actions,
|
||||
getters,
|
||||
modules: { video, chat, files, user, remote, settings, client, emoji },
|
||||
modules: { video, chat, user, remote, settings, client, emoji },
|
||||
}
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
@ -6,6 +6,7 @@
|
||||
* [Reverse Proxy](/getting-started/reverse-proxy)
|
||||
* [Configuration](/getting-started/configuration)
|
||||
* [Troubleshooting](/getting-started/troubleshooting)
|
||||
* [Frequently Asked Questions](/getting-started/faq)
|
||||
* [Mobile Support](/mobile-support)
|
||||
* [Contributing](/contributing)
|
||||
* [Non Goals](/non-goals)
|
||||
|
@ -4,6 +4,20 @@
|
||||
|
||||
### New Features
|
||||
- Added nvidia support for firefox.
|
||||
- Added `?lang=<lang>` parameter to the URL, which will set the language of the interface (by @mbattista).
|
||||
- Added `?show_side=1` and `?mute_chat=1` parameter to the URL, for chat mute and show side (by @mbattista).
|
||||
|
||||
### Bugs
|
||||
- Fix incorrect version sorting for chromium, microsoft-edge, opera and ungoogledchromium.
|
||||
- Fix buffer overflow in Gstreamer log function [#382](https://github.com/m1k1o/neko/pull/382) (by @@tt2468).
|
||||
|
||||
### Misc
|
||||
- Added RTMP broadcast support to nvidia docker image [#274](https://github.com/m1k1o/neko/issues/274).
|
||||
- Ensured that paths are writable by neko user [#277](https://github.com/m1k1o/neko/issues/277).
|
||||
- Git commit and tag are now included in the build when creating a docker image.
|
||||
- Remove any temporary files associated with a Form after file upload, that would be otherwise never removed.
|
||||
- Add check for volume parameter in URL before setting volume (by @FapFapDragon).
|
||||
- Add glib main loop to capture manager [#383](https://github.com/m1k1o/neko/pull/383) (by @tt2468).
|
||||
|
||||
## [n.eko v2.8.0](https://github.com/m1k1o/neko/releases/tag/v2.8.0)
|
||||
|
||||
|
@ -10,7 +10,7 @@
|
||||
|
||||
## Server build dependencies
|
||||
|
||||
If you want to compile goalng code locally, you must install additional dependencies in order for it to compile.
|
||||
If you want to compile Golang code locally, you must install additional dependencies in order for it to compile.
|
||||
|
||||
```shell
|
||||
apt-get install -y --no-install-recommends libx11-dev libxrandr-dev libxtst-dev libgstreamer1.0-dev
|
||||
|
@ -50,7 +50,7 @@ All images are also available on [GitHub Container Registry](https://github.com/
|
||||
- `ghcr.io/m1k1o/neko/xfce:latest`
|
||||
- `ghcr.io/m1k1o/neko/kde:latest`
|
||||
|
||||
For ARM-based images (like Raspberry Pi - with GPU hardware acceleration, Oracle Cloud ARM tier). Currently, not all images are available for ARM, because not all applications are available for ARM.
|
||||
For ARM-based images (like Raspberry Pi - with GPU hardware acceleration, Oracle Cloud ARM tier). Currently, not all images are available for ARM, because not all applications are available for ARM. Please note, that `m1k1o/neko:arm-*` images from dockerhub are currently not maintained and they can contain outdated software. Please use images below:
|
||||
|
||||
- `ghcr.io/m1k1o/neko/arm-firefox:latest`
|
||||
- `ghcr.io/m1k1o/neko/arm-chromium:latest`
|
||||
@ -74,8 +74,9 @@ For images with VAAPI GPU hardware acceleration using intel drivers use:
|
||||
- `ghcr.io/m1k1o/neko/intel-xfce:latest`
|
||||
- `ghcr.io/m1k1o/neko/intel-kde:latest`
|
||||
|
||||
For images with Nvidia GPU hardware acceleration using EGL use:
|
||||
For images with Nvidia GPU hardware acceleration using EGL (see example below) use (please note, there is a known issue with EGL and Chromium-based browsers, see [here](https://github.com/m1k1o/neko/issues/279)):
|
||||
|
||||
- `ghcr.io/m1k1o/neko/nvidia-firefox:latest`
|
||||
- `ghcr.io/m1k1o/neko/nvidia-chromium:latest`
|
||||
- `ghcr.io/m1k1o/neko/nvidia-google-chrome:latest`
|
||||
- `ghcr.io/m1k1o/neko/nvidia-microsoft-edge:latest`
|
||||
@ -86,7 +87,48 @@ GHCR images are built using GitHub actions for every tag.
|
||||
### Networking:
|
||||
- If you want to use n.eko in **external** network, you can omit `NEKO_NAT1TO1`. It will automatically get your Public IP.
|
||||
- If you want to use n.eko in **internal** network, set `NEKO_NAT1TO1` to your local IP address (e.g. `NEKO_NAT1TO1: 192.168.1.20`)-
|
||||
- Currently, it is not supported to supply multiple NAT addresses (see https://github.com/m1k1o/neko/issues/47).
|
||||
|
||||
Currently, it is not supported to supply multiple NAT addresses directly to neko (see https://github.com/m1k1o/neko/issues/47).
|
||||
|
||||
But it can be acheived by deploying own turn server alongside neko that is accessible from your LAN:
|
||||
|
||||
```yaml
|
||||
version: "3.4"
|
||||
services:
|
||||
neko:
|
||||
image: "m1k1o/neko:firefox"
|
||||
restart: "unless-stopped"
|
||||
shm_size: "2gb"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "52000-52100:52000-52100/udp"
|
||||
environment:
|
||||
NEKO_SCREEN: 1920x1080@30
|
||||
NEKO_PASSWORD: neko
|
||||
NEKO_PASSWORD_ADMIN: admin
|
||||
NEKO_EPR: 52000-52100
|
||||
NEKO_ICESERVERS: '[{ "urls": [ "turn:192.168.1.60:3478" ], "username":"neko", "credential":"neko" }, { "urls": [ "stun:stun.nextcloud.com:3478" ] }]'
|
||||
coturn:
|
||||
image: 'coturn/coturn:latest'
|
||||
network_mode: "host"
|
||||
command: |
|
||||
-n
|
||||
--realm=localhost
|
||||
--fingerprint
|
||||
--listening-ip=0.0.0.0
|
||||
--external-ip=192.168.1.60
|
||||
--listening-port=3478
|
||||
--min-port=49160
|
||||
--max-port=49200
|
||||
--log-file=stdout
|
||||
--user=neko:neko
|
||||
--lt-cred-mech
|
||||
```
|
||||
|
||||
- Replace `192.168.1.60` with your LAN IP address, and allow ports `49160-49200/udp` and `3478/tcp` in your LAN.
|
||||
- Make sure you don't use `NEKO_ICELITE: true` because ICE LITE does not support TURN servers.
|
||||
|
||||
This setup adds local turn server to neko. It won't be reachable by your remote clients and your own IP won't be reachable from your lan. So it effectively just adds local candidate and allows connections from LAN.
|
||||
|
||||
### Why so many ports?
|
||||
- WebRTC needs UDP ports in order to transfer Audio/Video towards user and Mouse/Keyboard events to the server in real time.
|
||||
@ -131,6 +173,17 @@ services:
|
||||
- UDP is generally better for latency. But some networks block UDP so it is good to have TCP available as fallback.
|
||||
- Still, using `NEKO_ICELITE=true` is recommended.
|
||||
|
||||
### Using turn servers instead of port forwarding
|
||||
|
||||
- If you don't want to use port forwarding, you can use turn servers.
|
||||
- But you need to have your own turn server (e.g. [cotrun](https://github.com/coturn/coturn)) or have access to one.
|
||||
- They are generally not free, because they require a lot of bandwidth.
|
||||
- Please make sure that you correctly escape your turn server credentials in the environment variable or use aphostrophes.
|
||||
|
||||
```yaml
|
||||
NEKO_ICESERVERS: '[{"urls": ["turn:<MY-COTURN-SERVER>:443?transport=udp", "turn:<MY-COTURN-SERVER>:443?transport=tcp", "turns:<MY-COTURN-SERVER>:443?transport=udp", "turns:<MY-COTURN-SERVER>:443?transport=tcp"], "credential": "<MY-COTURN-CREDENTIAL"}, {"urls": ["stun:stun.nextcloud.com:443"]}]'
|
||||
```
|
||||
|
||||
### Want to customize and install own add-ons, set custom bookmarks?
|
||||
- You would need to modify the existing policy file and mount it to your container.
|
||||
- For Firefox, copy [this](https://github.com/m1k1o/neko/blob/master/.docker/firefox/policies.json) file, modify and mount it as: ` -v '${PWD}/policies.json:/usr/lib/firefox/distribution/policies.json'`
|
||||
@ -164,7 +217,7 @@ services:
|
||||
|
||||
### Nvidia GPU acceleration
|
||||
|
||||
You need to have [nvidia-docker](https://github.com/NVIDIA/nvidia-docker) installed, start the container with `--gpus all` flag and use images built for nvidia (see above).
|
||||
You need to have [NVIDIA Container Toolkit](https://github.com/NVIDIA/nvidia-container-toolkit) installed, start the container with `--gpus all` flag and use images built for nvidia (see above).
|
||||
|
||||
```bash
|
||||
docker run -d --gpus all \
|
||||
@ -219,6 +272,12 @@ services:
|
||||
- You can verify that GPU is used for encoding by searching for `nvh264enc` in `docker logs neko` output.
|
||||
- If you don'ŧ specify `NEKO_HWENC: nvenc` environment variable, CPU encoding will be used but GPU will still be available for browser rendering.
|
||||
|
||||
Broadcast pipeline is not hardware accelerated by default. You can use this pipeline created by [@evilalmus](https://github.com/m1k1o/neko/issues/276#issuecomment-1498362533).
|
||||
|
||||
```yaml
|
||||
NEKO_BROADCAST_PIPELINE: "flvmux name=mux ! rtmpsink location={url} pulsesrc device={device} ! audio/x-raw,channels=2 ! audioconvert ! voaacenc ! mux. ximagesrc display-name={display} show-pointer=false use-damage=false ! video/x-raw,framerate=30/1 ! videoconvert ! queue ! video/x-raw,format=NV12 ! nvh264enc name=encoder preset=low-latency-hq gop-size=25 spatial-aq=true temporal-aq=true bitrate=2800 vbv-buffer-size=2800 rc-mode=6 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,profile=high ! h264parse ! mux."
|
||||
```
|
||||
|
||||
### Want to use VPN for your n.eko browsing?
|
||||
- Check this out: https://github.com/m1k1o/neko-vpn
|
||||
|
||||
@ -238,6 +297,9 @@ services:
|
||||
- Adding `?cast=1` will hide all control and show only video.
|
||||
- Adding `?embed=1` will hide most additional components and show only video.
|
||||
- Adding `?volume=<0-1>` will set volume to given value.
|
||||
- Adding `?lang=<language>` will set language to given value.
|
||||
- Adding `?show_side=1` will show the sidebar on startup.
|
||||
- Adding `?mute_chat=1` will mute the chat on startup.
|
||||
- e.g. `http(s)://<URL:Port>/?pwd=neko&usr=guest&cast=1`
|
||||
|
||||
### Screen size
|
||||
|
@ -72,7 +72,8 @@ services:
|
||||
version: "3.4"
|
||||
services:
|
||||
neko:
|
||||
image: "m1k1o/neko:arm-chromium"
|
||||
# see docs for more variants
|
||||
image: "ghcr.io/m1k1o/neko/arm-chromium:latest"
|
||||
restart: "unless-stopped"
|
||||
# increase on rpi's with more then 1gb ram.
|
||||
shm_size: "520mb"
|
||||
|
57
docs/getting-started/faq.md
Normal file
57
docs/getting-started/faq.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## How to enable debug mode?
|
||||
|
||||
To see verbose information from n.eko server, you can enable debug mode using `NEKO_DEBUG`.
|
||||
|
||||
```diff
|
||||
version: "3.4"
|
||||
services:
|
||||
neko:
|
||||
image: "m1k1o/neko:firefox"
|
||||
restart: "unless-stopped"
|
||||
shm_size: "2gb"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "52000-52100:52000-52100/udp"
|
||||
environment:
|
||||
NEKO_SCREEN: 1920x1080@30
|
||||
NEKO_PASSWORD: neko
|
||||
NEKO_PASSWORD_ADMIN: admin
|
||||
NEKO_EPR: 52000-52100
|
||||
NEKO_ICELITE: 1
|
||||
+ NEKO_DEBUG: 1
|
||||
```
|
||||
|
||||
Ensure, that you have enabled debug mode in javascript console too, in order to see verbose information from client.
|
||||
|
||||
## Chinese input method is not working
|
||||
|
||||
There exists an extension for Chrome that allows you to use Chinese input method. You can install it from [here](https://chrome.google.com/webstore/detail/mclkkofklkfljcocdinagocijmpgbhab). Alternatively, you can use Google Input Tools from [here](https://www.google.com/inputtools/chrome/).
|
||||
|
||||
## Only black screen is displayed but remote cursor is moving for Chromium-based browsers (Chrome, Edge, etc.)
|
||||
|
||||
Check if you did not forget to add cap_add to your docker-compose file.
|
||||
|
||||
```yaml
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
```
|
||||
|
||||
## How can I embed the Neko desktop into web page without login prompt coming up for viewers?
|
||||
|
||||
You can use the following URL to embed the Neko desktop into a web page without login prompt coming up for viewers:
|
||||
|
||||
```
|
||||
http://<your-neko-server-ip>:8080/?usr=neko&pwd=neko
|
||||
```
|
||||
|
||||
https://stackoverflow.com/questions/15276929/how-to-make-a-video-fullscreen-when-it-is-placed-inside-an-iframe
|
||||
|
||||
Your iframe needs an attribute: `allowfullscreen="true" webkitallowfullscreen="true" mozallowfullscreen="true"` or more modern `allow="fullscreen *"`. For the second you can remove the star if your iframe has the same origin or replace it with your iframe origin.
|
||||
|
||||
## Can I use neko without docker?
|
||||
|
||||
It is strongly recommended to use Neko with Docker, as it is the easiest way to run it. But you should be able to install Neko "natively" on your host system. Neko is based on Debian and uses Xorg and Pulseaudio. You would just need to follow steps that are in Dockerfile, install all dependencies on your host system and then just run it.
|
||||
|
||||
However, it is recommend to start with existing system that has GUI with desktop manager, is based on Xorg and uses Pulseaudio (e.g. Ubuntu Desktop 22.04). For that matter you only need to install gstreamer dependencies, configure pulseaudio properly and run neko binary (you don't need to build it from scratch, you can copy it from docker image).
|
@ -94,7 +94,7 @@ services:
|
||||
+ NEKO_IPFETCH: https://ifconfig.co/ip
|
||||
```
|
||||
|
||||
Or you can specify your IP address manually using `NEKO_NAT1TO1`:
|
||||
Or you can specify your IP address manually using `NEKO_NAT1TO1`: (It's read as NAT 1 to 1, so it's capital letter 'O', not zero '0', in NAT1`TO`1)
|
||||
|
||||
```diff
|
||||
version: "3.4"
|
||||
@ -129,6 +129,7 @@ Example for pfsense with truecharts docker container:
|
||||
- Test externally to confirm it works.
|
||||
- Internally you have to access it using `<your-public-ip>:port`
|
||||
|
||||
If your router does not support NAT Loopback (NAT Hairpinning), you can use turn servers to overcome this issue. See [more details here](https://neko.m1k1o.net/#/getting-started/?id=networking) on how to setup local coturn instance.
|
||||
|
||||
### Neko works locally, but not externally
|
||||
|
||||
@ -210,3 +211,17 @@ Most likely you forgot to add `-cap-add=SYS_ADMIN` when using chromium-based bro
|
||||
```
|
||||
|
||||
This error originates from browser, that it could not connect to dbus. This does not affect us and can be ignored.
|
||||
|
||||
### Broadcast pipeline not working with some ingest servers
|
||||
|
||||
See [related issue](https://github.com/m1k1o/neko/issues/276).
|
||||
|
||||
```
|
||||
Could not connect to RTMP stream "'rtmp://<ingest-url>/live/<stream-key-removed> live=1'" for writing
|
||||
```
|
||||
|
||||
Some ingest servers require `live=1` parameter in the URL (e.g. nginx-rtmp-module). Some do not and do not accept aphostrophes (e.g. owncast). You can try to change the pipeline to:
|
||||
|
||||
```yaml
|
||||
NEKO_BROADCAST_PIPELINE: "flvmux name=mux ! rtmpsink location={url} pulsesrc device={device} ! audio/x-raw,channels=2 ! audioconvert ! voaacenc ! mux. ximagesrc display-name={display} show-pointer=false use-damage=false ! video/x-raw,framerate=28/1 ! videoconvert ! queue ! x264enc bframes=0 key-int-max=0 byte-stream=true tune=zerolatency speed-preset=veryfast ! mux."
|
||||
```
|
||||
|
17
server/build
17
server/build
@ -1,24 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
#
|
||||
# aborting if any command returns a non-zero value
|
||||
set -e
|
||||
|
||||
BUILD_TIME=`date -u +'%Y-%m-%dT%H:%M:%SZ'`
|
||||
|
||||
#
|
||||
# set git build variables if git exists
|
||||
if git status > /dev/null 2>&1 && [ -z $GIT_COMMIT ] && [ -z $GIT_BRANCH ] && [ -z $GIT_DIRTY ];
|
||||
if git status > /dev/null 2>&1 && [ -z $GIT_COMMIT ] && [ -z $GIT_BRANCH ] && [ -z $GIT_TAG ];
|
||||
then
|
||||
GIT_COMMIT=`git rev-parse --short HEAD`
|
||||
GIT_BRANCH=`git rev-parse --symbolic-full-name --abbrev-ref HEAD`
|
||||
GIT_TAG=`git tag --points-at $GIT_COMMIT | head -n 1`
|
||||
GIT_DIRTY=`git diff-index --quiet HEAD -- || echo "✗-"`
|
||||
GIT_COMMIT="${GIT_DIRTY}${GIT_COMMIT}"
|
||||
fi
|
||||
|
||||
#
|
||||
# load dependencies
|
||||
go get -v -t -d .
|
||||
|
||||
#
|
||||
# build server
|
||||
go build \
|
||||
-o bin/neko \
|
||||
-ldflags "
|
||||
-s -w
|
||||
-X 'm1k1o/neko.buildDate=${BUILD_TIME}'
|
||||
-X 'm1k1o/neko.gitCommit=${GIT_DIRTY}${GIT_COMMIT}'
|
||||
-X 'm1k1o/neko.gitCommit=${GIT_COMMIT}'
|
||||
-X 'm1k1o/neko.gitBranch=${GIT_BRANCH}'
|
||||
-X 'm1k1o/neko.gitTag=${GIT_TAG}'
|
||||
" \
|
||||
cmd/neko/main.go;
|
||||
|
@ -4,7 +4,7 @@ go 1.20
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/go-chi/chi v4.1.2+incompatible
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/pion/ice/v2 v2.3.0
|
||||
|
@ -63,8 +63,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
||||
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
|
@ -3,8 +3,8 @@
|
||||
static void gstreamer_pipeline_log(GstPipelineCtx *ctx, char* level, const char* format, ...) {
|
||||
va_list argptr;
|
||||
va_start(argptr, format);
|
||||
char buffer[100];
|
||||
vsprintf(buffer, format, argptr);
|
||||
char buffer[4096];
|
||||
vsnprintf(buffer, sizeof(buffer), format, argptr);
|
||||
va_end(argptr);
|
||||
goPipelineLog(level, buffer, ctx->pipelineId);
|
||||
}
|
||||
|
@ -31,12 +31,29 @@ var pSerial int32
|
||||
var pipelines = make(map[int]*Pipeline)
|
||||
var pipelinesLock sync.Mutex
|
||||
var registry *C.GstRegistry
|
||||
var gMainLoop *C.GMainLoop
|
||||
|
||||
func init() {
|
||||
C.gst_init(nil, nil)
|
||||
registry = C.gst_registry_get()
|
||||
}
|
||||
|
||||
func RunMainLoop() {
|
||||
if gMainLoop != nil {
|
||||
return
|
||||
}
|
||||
gMainLoop = C.g_main_loop_new(nil, C.int(0))
|
||||
C.g_main_loop_run(gMainLoop)
|
||||
}
|
||||
|
||||
func QuitMainLoop() {
|
||||
if gMainLoop == nil {
|
||||
return
|
||||
}
|
||||
C.g_main_loop_quit(gMainLoop)
|
||||
gMainLoop = nil
|
||||
}
|
||||
|
||||
func CreatePipeline(pipelineStr string) (*Pipeline, error) {
|
||||
id := atomic.AddInt32(&pSerial, 1)
|
||||
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"m1k1o/neko/internal/capture/gst"
|
||||
"m1k1o/neko/internal/config"
|
||||
"m1k1o/neko/internal/types"
|
||||
)
|
||||
@ -53,6 +54,7 @@ func (manager *CaptureManagerCtx) Start() {
|
||||
}
|
||||
}
|
||||
|
||||
go gst.RunMainLoop()
|
||||
go func() {
|
||||
for {
|
||||
before, ok := <-manager.desktop.GetScreenSizeChangeChannel()
|
||||
@ -100,6 +102,8 @@ func (manager *CaptureManagerCtx) Shutdown() error {
|
||||
manager.audio.shutdown()
|
||||
manager.video.shutdown()
|
||||
|
||||
gst.QuitMainLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,7 @@ type Server struct {
|
||||
Cert string
|
||||
Key string
|
||||
Bind string
|
||||
Proxy bool
|
||||
Static string
|
||||
PathPrefix string
|
||||
CORS []string
|
||||
@ -35,6 +36,11 @@ func (Server) Init(cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("proxy", false, "enable reverse proxy mode")
|
||||
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("static", "./www", "path to neko client files to serve")
|
||||
if err := viper.BindPFlag("static", cmd.PersistentFlags().Lookup("static")); err != nil {
|
||||
return err
|
||||
@ -57,6 +63,7 @@ func (s *Server) Set() {
|
||||
s.Cert = viper.GetString("cert")
|
||||
s.Key = viper.GetString("key")
|
||||
s.Bind = viper.GetString("bind")
|
||||
s.Proxy = viper.GetBool("proxy")
|
||||
s.Static = viper.GetString("static")
|
||||
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@ -10,13 +8,6 @@ import (
|
||||
type WebSocket struct {
|
||||
Password string
|
||||
AdminPassword string
|
||||
Proxy bool
|
||||
Locks []string
|
||||
|
||||
ControlProtection bool
|
||||
|
||||
FileTransferEnabled bool
|
||||
FileTransferPath string
|
||||
}
|
||||
|
||||
func (WebSocket) Init(cmd *cobra.Command) error {
|
||||
@ -30,45 +21,10 @@ func (WebSocket) Init(cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("proxy", false, "enable reverse proxy mode")
|
||||
if err := viper.BindPFlag("proxy", cmd.PersistentFlags().Lookup("proxy")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringSlice("locks", []string{}, "resources, that will be locked when starting (control, login)")
|
||||
if err := viper.BindPFlag("locks", cmd.PersistentFlags().Lookup("locks")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("control_protection", false, "control protection means, users can gain control only if at least one admin is in the room")
|
||||
if err := viper.BindPFlag("control_protection", cmd.PersistentFlags().Lookup("control_protection")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// File transfer
|
||||
|
||||
cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature")
|
||||
if err := viper.BindPFlag("file_transfer_enabled", cmd.PersistentFlags().Lookup("file_transfer_enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("file_transfer_path", "/home/neko/Downloads", "path to use for file transfer")
|
||||
if err := viper.BindPFlag("file_transfer_path", cmd.PersistentFlags().Lookup("file_transfer_path")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebSocket) Set() {
|
||||
s.Password = viper.GetString("password")
|
||||
s.AdminPassword = viper.GetString("password_admin")
|
||||
s.Proxy = viper.GetBool("proxy")
|
||||
s.Locks = viper.GetStringSlice("locks")
|
||||
|
||||
s.ControlProtection = viper.GetBool("control_protection")
|
||||
|
||||
s.FileTransferEnabled = viper.GetBool("file_transfer_enabled")
|
||||
s.FileTransferPath = viper.GetString("file_transfer_path")
|
||||
s.FileTransferPath = filepath.Clean(s.FileTransferPath)
|
||||
}
|
||||
|
@ -3,16 +3,13 @@ package http
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -21,8 +18,6 @@ import (
|
||||
"m1k1o/neko/internal/types"
|
||||
)
|
||||
|
||||
const FILE_UPLOAD_BUF_SIZE = 65000
|
||||
|
||||
type Server struct {
|
||||
logger zerolog.Logger
|
||||
router *chi.Mux
|
||||
@ -35,6 +30,9 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID) // Create a request ID for each request
|
||||
if conf.Proxy {
|
||||
router.Use(middleware.RealIP)
|
||||
}
|
||||
router.Use(middleware.RequestLogger(&logformatter{logger}))
|
||||
router.Use(middleware.Recoverer) // Recover from panics without crashing server
|
||||
router.Use(middleware.Compress(5, "application/octet-stream"))
|
||||
@ -95,11 +93,6 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
||||
return
|
||||
}
|
||||
|
||||
if webSocketHandler.IsLocked("login") {
|
||||
http.Error(w, "room is locked", http.StatusLocked)
|
||||
return
|
||||
}
|
||||
|
||||
quality, err := strconv.Atoi(r.URL.Query().Get("quality"))
|
||||
if err != nil {
|
||||
quality = 90
|
||||
@ -115,78 +108,6 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
||||
}
|
||||
})
|
||||
|
||||
// allow downloading and uploading files
|
||||
if webSocketHandler.FileTransferEnabled() {
|
||||
router.Get("/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.URL.Query().Get("pwd")
|
||||
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !isAuthorized {
|
||||
http.Error(w, "bad authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
filename := r.URL.Query().Get("filename")
|
||||
badChars, _ := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename)
|
||||
if filename == "" || badChars {
|
||||
http.Error(w, "bad filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := webSocketHandler.FileTransferPath(filename)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "not found or unable to open", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
io.Copy(w, f)
|
||||
})
|
||||
|
||||
router.Post("/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.URL.Query().Get("pwd")
|
||||
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !isAuthorized {
|
||||
http.Error(w, "bad authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseMultipartForm(32 << 20)
|
||||
for _, formheader := range r.MultipartForm.File["files"] {
|
||||
filePath := webSocketHandler.FileTransferPath(formheader.Filename)
|
||||
|
||||
formfile, err := formheader.Open()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to open formdata file")
|
||||
http.Error(w, "error writing file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer formfile.Close()
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to open file for writing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
io.Copy(f, formfile)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("true"))
|
||||
})
|
||||
|
@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
|
@ -28,8 +28,6 @@ type SessionManager struct {
|
||||
capture types.CaptureManager
|
||||
members map[string]*Session
|
||||
eventsChannel chan types.SessionEvent
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
controlLocked bool
|
||||
}
|
||||
|
||||
func (manager *SessionManager) New(id string, admin bool, socket types.WebSocket) types.Session {
|
||||
@ -118,16 +116,6 @@ func (manager *SessionManager) Get(id string) (types.Session, bool) {
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
func (manager *SessionManager) SetControlLocked(locked bool) {
|
||||
manager.controlLocked = locked
|
||||
}
|
||||
|
||||
func (manager *SessionManager) CanControl(id string) bool {
|
||||
session, ok := manager.Get(id)
|
||||
return ok && (!manager.controlLocked || session.Admin())
|
||||
}
|
||||
|
||||
func (manager *SessionManager) Admins() []*types.Member {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
@ -34,11 +34,6 @@ const (
|
||||
CHAT_EMOTE = "chat/emote"
|
||||
)
|
||||
|
||||
const (
|
||||
FILETRANSFER_LIST = "filetransfer/list"
|
||||
FILETRANSFER_REFRESH = "filetransfer/refresh"
|
||||
)
|
||||
|
||||
const (
|
||||
SCREEN_CONFIGURATIONS = "screen/configurations"
|
||||
SCREEN_RESOLUTION = "screen/resolution"
|
||||
@ -54,9 +49,7 @@ const (
|
||||
const (
|
||||
ADMIN_BAN = "admin/ban"
|
||||
ADMIN_KICK = "admin/kick"
|
||||
ADMIN_LOCK = "admin/lock"
|
||||
ADMIN_MUTE = "admin/mute"
|
||||
ADMIN_UNLOCK = "admin/unlock"
|
||||
ADMIN_UNMUTE = "admin/unmute"
|
||||
ADMIN_CONTROL = "admin/control"
|
||||
ADMIN_RELEASE = "admin/release"
|
||||
|
@ -11,10 +11,8 @@ type Message struct {
|
||||
}
|
||||
|
||||
type SystemInit struct {
|
||||
Event string `json:"event"`
|
||||
ImplicitHosting bool `json:"implicit_hosting"`
|
||||
Locks map[string]string `json:"locks"`
|
||||
FileTransfer bool `json:"file_transfer"`
|
||||
Event string `json:"event"`
|
||||
ImplicitHosting bool `json:"implicit_hosting"`
|
||||
}
|
||||
|
||||
type SystemMessage struct {
|
||||
@ -107,12 +105,6 @@ type EmoteSend struct {
|
||||
Emote string `json:"emote"`
|
||||
}
|
||||
|
||||
type FileTransferList struct {
|
||||
Event string `json:"event"`
|
||||
Cwd string `json:"cwd"`
|
||||
Files []types.FileListItem `json:"files"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
Event string `json:"event"`
|
||||
ID string `json:"id"`
|
||||
|
@ -55,8 +55,6 @@ type SessionManager interface {
|
||||
ClearHost()
|
||||
Has(id string) bool
|
||||
Get(id string) (Session, bool)
|
||||
SetControlLocked(locked bool)
|
||||
CanControl(id string) bool
|
||||
Members() []*Member
|
||||
Admins() []*Member
|
||||
Destroy(id string)
|
||||
|
@ -11,14 +11,12 @@ type Stats struct {
|
||||
Members []*Member `json:"members"`
|
||||
|
||||
Banned map[string]string `json:"banned"` // IP -> session ID (that banned it)
|
||||
Locked map[string]string `json:"locked"` // resource name -> session ID (that locked it)
|
||||
|
||||
ServerStartedAt time.Time `json:"server_started_at"`
|
||||
LastAdminLeftAt *time.Time `json:"last_admin_left_at"`
|
||||
LastUserLeftAt *time.Time `json:"last_user_left_at"`
|
||||
|
||||
ControlProtection bool `json:"control_protection"`
|
||||
ImplicitControl bool `json:"implicit_control"`
|
||||
ImplicitControl bool `json:"implicit_control"`
|
||||
}
|
||||
|
||||
type WebSocket interface {
|
||||
@ -32,17 +30,5 @@ type WebSocketHandler interface {
|
||||
Shutdown() error
|
||||
Upgrade(w http.ResponseWriter, r *http.Request) error
|
||||
Stats() Stats
|
||||
IsLocked(resource string) bool
|
||||
IsAdmin(password string) (bool, error)
|
||||
|
||||
// File Transfer
|
||||
CanTransferFiles(password string) (bool, error)
|
||||
FileTransferPath(filename string) string
|
||||
FileTransferEnabled() bool
|
||||
}
|
||||
|
||||
type FileListItem struct {
|
||||
Filename string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
@ -1,36 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"m1k1o/neko/internal/types"
|
||||
)
|
||||
|
||||
func ListFiles(path string) ([]types.FileListItem, error) {
|
||||
items, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]types.FileListItem, len(items))
|
||||
for i, item := range items {
|
||||
var itemType string = ""
|
||||
var size int64 = 0
|
||||
if item.IsDir() {
|
||||
itemType = "dir"
|
||||
} else {
|
||||
itemType = "file"
|
||||
info, err := item.Info()
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
}
|
||||
out[i] = types.FileListItem{
|
||||
Filename: item.Name(),
|
||||
Type: itemType,
|
||||
Size: size,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
@ -2,7 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
@ -31,21 +31,10 @@ func GetIP(serverUrl string) (string, error) {
|
||||
}
|
||||
defer rsp.Body.Close()
|
||||
|
||||
buf, err := ioutil.ReadAll(rsp.Body)
|
||||
buf, err := io.ReadAll(rsp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(buf)), nil
|
||||
}
|
||||
|
||||
func GetHttpRequestIP(r *http.Request, proxy bool) string {
|
||||
IPAddress := r.Header.Get("X-Real-Ip")
|
||||
if IPAddress == "" {
|
||||
IPAddress = r.Header.Get("X-Forwarded-For")
|
||||
}
|
||||
if IPAddress == "" || !proxy {
|
||||
IPAddress = r.RemoteAddr
|
||||
}
|
||||
return IPAddress
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ type PayloadKey struct {
|
||||
}
|
||||
|
||||
func (manager *WebRTCManager) handle(id string, msg webrtc.DataChannelMessage) error {
|
||||
if (!manager.config.ImplicitControl && !manager.sessions.IsHost(id)) || (manager.config.ImplicitControl && !manager.sessions.CanControl(id)) {
|
||||
if (!manager.config.ImplicitControl && !manager.sessions.IsHost(id)) || manager.config.ImplicitControl {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -8,78 +8,6 @@ import (
|
||||
"m1k1o/neko/internal/types/message"
|
||||
)
|
||||
|
||||
func (h *MessageHandler) adminLock(id string, session types.Session, payload *message.AdminLock) error {
|
||||
if !session.Admin() {
|
||||
h.logger.Debug().Msg("user not admin")
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.state.IsLocked(payload.Resource) {
|
||||
h.logger.Debug().Str("resource", payload.Resource).Msg("resource already locked...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// allow only known resources
|
||||
switch payload.Resource {
|
||||
case "login":
|
||||
case "control":
|
||||
case "file_transfer":
|
||||
default:
|
||||
h.logger.Debug().Msg("unknown lock resource")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
if payload.Resource == "control" {
|
||||
h.sessions.SetControlLocked(true)
|
||||
}
|
||||
|
||||
h.state.Lock(payload.Resource, id)
|
||||
|
||||
if err := h.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_LOCK,
|
||||
ID: id,
|
||||
Resource: payload.Resource,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MessageHandler) adminUnlock(id string, session types.Session, payload *message.AdminLock) error {
|
||||
if !session.Admin() {
|
||||
h.logger.Debug().Msg("user not admin")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.state.IsLocked(payload.Resource) {
|
||||
h.logger.Debug().Str("resource", payload.Resource).Msg("resource not locked...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
if payload.Resource == "control" {
|
||||
h.sessions.SetControlLocked(false)
|
||||
}
|
||||
|
||||
h.state.Unlock(payload.Resource)
|
||||
|
||||
if err := h.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_UNLOCK,
|
||||
ID: id,
|
||||
Resource: payload.Resource,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MessageHandler) adminControl(id string, session types.Session) error {
|
||||
if !session.Admin() {
|
||||
h.logger.Debug().Msg("user not admin")
|
||||
|
@ -33,12 +33,6 @@ func (h *MessageHandler) controlRelease(id string, session types.Session) error
|
||||
func (h *MessageHandler) controlRequest(id string, session types.Session) error {
|
||||
// check for host
|
||||
if !h.sessions.HasHost() {
|
||||
// check if control is locked or user is admin
|
||||
if h.state.IsLocked("control") && !session.Admin() {
|
||||
h.logger.Debug().Msg("control is locked")
|
||||
return nil
|
||||
}
|
||||
|
||||
// set host
|
||||
err := h.sessions.SetHost(id)
|
||||
if err != nil {
|
||||
@ -96,12 +90,6 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
|
||||
return nil
|
||||
}
|
||||
|
||||
// check if control is locked or giver is admin
|
||||
if h.state.IsLocked("control") && !session.Admin() {
|
||||
h.logger.Debug().Msg("control is locked")
|
||||
return nil
|
||||
}
|
||||
|
||||
// set host
|
||||
err := h.sessions.SetHost(payload.ID)
|
||||
if err != nil {
|
||||
@ -124,7 +112,7 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
|
||||
|
||||
func (h *MessageHandler) controlClipboard(id string, session types.Session, payload *message.Clipboard) error {
|
||||
// check if session can access clipboard
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || (h.webrtc.ImplicitControl() && !h.sessions.CanControl(id)) {
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || h.webrtc.ImplicitControl() {
|
||||
h.logger.Debug().Str("id", id).Msg("cannot access clipboard")
|
||||
return nil
|
||||
}
|
||||
@ -135,7 +123,7 @@ func (h *MessageHandler) controlClipboard(id string, session types.Session, payl
|
||||
|
||||
func (h *MessageHandler) controlKeyboard(id string, session types.Session, payload *message.Keyboard) error {
|
||||
// check if session can control keyboard
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || (h.webrtc.ImplicitControl() && !h.sessions.CanControl(id)) {
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || h.webrtc.ImplicitControl() {
|
||||
h.logger.Debug().Str("id", id).Msg("cannot control keyboard")
|
||||
return nil
|
||||
}
|
||||
|
@ -1,47 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"m1k1o/neko/internal/types"
|
||||
"m1k1o/neko/internal/types/event"
|
||||
"m1k1o/neko/internal/types/message"
|
||||
"m1k1o/neko/internal/utils"
|
||||
)
|
||||
|
||||
func (h *MessageHandler) FileTransferRefresh(session types.Session) error {
|
||||
if !h.state.FileTransferEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileTransferPath := h.state.FileTransferPath("") // root
|
||||
|
||||
// allow users only if file transfer is not locked
|
||||
if session != nil && !(session.Admin() || !h.state.IsLocked("file_transfer")) {
|
||||
h.logger.Debug().Msg("file transfer is locked for users")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: keep list of files in memory and update it on file changes
|
||||
files, err := utils.ListFiles(fileTransferPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := message.FileTransferList{
|
||||
Event: event.FILETRANSFER_LIST,
|
||||
Cwd: fileTransferPath,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
// send to just one user
|
||||
if session != nil {
|
||||
return session.Send(message)
|
||||
}
|
||||
|
||||
// broadcast to all admins
|
||||
if h.state.IsLocked("file_transfer") {
|
||||
return h.sessions.AdminBroadcast(message, nil)
|
||||
}
|
||||
|
||||
// broadcast to all users
|
||||
return h.sessions.Broadcast(message, nil)
|
||||
}
|
@ -50,11 +50,6 @@ func (h *MessageHandler) Connected(admin bool, address string) (bool, string) {
|
||||
}
|
||||
}
|
||||
|
||||
if h.state.IsLocked("login") && !admin {
|
||||
h.logger.Debug().Msg("server locked")
|
||||
return false, "locked"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
@ -132,10 +127,6 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
|
||||
return h.chatEmote(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
|
||||
// File Transfer Events
|
||||
case event.FILETRANSFER_REFRESH:
|
||||
return errors.Wrapf(h.FileTransferRefresh(session), "%s failed", header.Event)
|
||||
|
||||
// Screen Events
|
||||
case event.SCREEN_RESOLUTION:
|
||||
return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event)
|
||||
@ -159,18 +150,6 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
|
||||
return errors.Wrapf(h.boradcastDestroy(session), "%s failed", header.Event)
|
||||
|
||||
// Admin Events
|
||||
case event.ADMIN_LOCK:
|
||||
payload := &message.AdminLock{}
|
||||
return errors.Wrapf(
|
||||
utils.Unmarshal(payload, raw, func() error {
|
||||
return h.adminLock(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
case event.ADMIN_UNLOCK:
|
||||
payload := &message.AdminLock{}
|
||||
return errors.Wrapf(
|
||||
utils.Unmarshal(payload, raw, func() error {
|
||||
return h.adminUnlock(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
case event.ADMIN_CONTROL:
|
||||
return errors.Wrapf(h.adminControl(id, session), "%s failed", header.Event)
|
||||
case event.ADMIN_RELEASE:
|
||||
|
@ -16,8 +16,6 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
|
||||
if err := session.Send(message.SystemInit{
|
||||
Event: event.SYSTEM_INIT,
|
||||
ImplicitHosting: h.webrtc.ImplicitControl(),
|
||||
Locks: h.state.AllLocked(),
|
||||
FileTransfer: h.state.FileTransferEnabled(),
|
||||
}); err != nil {
|
||||
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.SYSTEM_INIT)
|
||||
return err
|
||||
@ -35,13 +33,6 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
|
||||
}
|
||||
}
|
||||
|
||||
// send file list if file transfer is enabled
|
||||
if h.state.FileTransferEnabled() && (session.Admin() || !h.state.IsLocked("file_transfer")) {
|
||||
if err := h.FileTransferRefresh(session); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -1,22 +1,14 @@
|
||||
package state
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
type State struct {
|
||||
banned map[string]string // IP -> session ID (that banned it)
|
||||
locked map[string]string // resource name -> session ID (that locked it)
|
||||
|
||||
fileTransferEnabled bool
|
||||
fileTransferPath string // path where files are located
|
||||
}
|
||||
|
||||
func New(fileTransferEnabled bool, fileTransferPath string) *State {
|
||||
func New() *State {
|
||||
return &State{
|
||||
banned: make(map[string]string),
|
||||
locked: make(map[string]string),
|
||||
|
||||
fileTransferEnabled: fileTransferEnabled,
|
||||
fileTransferPath: fileTransferPath,
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,42 +35,3 @@ func (s *State) GetBanned(ip string) (string, bool) {
|
||||
func (s *State) AllBanned() map[string]string {
|
||||
return s.banned
|
||||
}
|
||||
|
||||
// Lock
|
||||
|
||||
func (s *State) Lock(resource, id string) {
|
||||
s.locked[resource] = id
|
||||
}
|
||||
|
||||
func (s *State) Unlock(resource string) {
|
||||
delete(s.locked, resource)
|
||||
}
|
||||
|
||||
func (s *State) IsLocked(resource string) bool {
|
||||
_, ok := s.locked[resource]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *State) GetLocked(resource string) (string, bool) {
|
||||
id, ok := s.locked[resource]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (s *State) AllLocked() map[string]string {
|
||||
return s.locked
|
||||
}
|
||||
|
||||
// File transfer
|
||||
|
||||
func (s *State) FileTransferPath(filename string) string {
|
||||
if filename == "" {
|
||||
return s.fileTransferPath
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(filename)
|
||||
return filepath.Join(s.fileTransferPath, cleanPath)
|
||||
}
|
||||
|
||||
func (s *State) FileTransferEnabled() bool {
|
||||
return s.fileTransferEnabled
|
||||
}
|
||||
|
@ -3,12 +3,10 @@ package websocket
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -22,35 +20,10 @@ import (
|
||||
"m1k1o/neko/internal/websocket/state"
|
||||
)
|
||||
|
||||
const CONTROL_PROTECTION_SESSION = "by_control_protection"
|
||||
|
||||
func New(sessions types.SessionManager, desktop types.DesktopManager, capture types.CaptureManager, webrtc types.WebRTCManager, conf *config.WebSocket) *WebSocketHandler {
|
||||
logger := log.With().Str("module", "websocket").Logger()
|
||||
|
||||
state := state.New(conf.FileTransferEnabled, conf.FileTransferPath)
|
||||
|
||||
// if control protection is enabled
|
||||
if conf.ControlProtection {
|
||||
state.Lock("control", CONTROL_PROTECTION_SESSION)
|
||||
logger.Info().Msgf("control locked on behalf of control protection")
|
||||
}
|
||||
|
||||
// create file transfer directory if not exists
|
||||
if conf.FileTransferEnabled {
|
||||
if _, err := os.Stat(conf.FileTransferPath); os.IsNotExist(err) {
|
||||
err = os.Mkdir(conf.FileTransferPath, os.ModePerm)
|
||||
logger.Err(err).Msg("creating file transfer directory")
|
||||
}
|
||||
}
|
||||
|
||||
// apply default locks
|
||||
for _, lock := range conf.Locks {
|
||||
state.Lock(lock, "") // empty session ID
|
||||
}
|
||||
|
||||
if len(conf.Locks) > 0 {
|
||||
logger.Info().Msgf("locked resources: %+v", conf.Locks)
|
||||
}
|
||||
state := state.New()
|
||||
|
||||
handler := handler.New(
|
||||
sessions,
|
||||
@ -123,24 +96,6 @@ func (ws *WebSocketHandler) Start() {
|
||||
ws.logger.Debug().Str("id", e.Id).Msg("session connected")
|
||||
}
|
||||
|
||||
// if control protection is enabled and at least one admin
|
||||
// and if room was locked on behalf control protection, unlock
|
||||
sess, ok := ws.state.GetLocked("control")
|
||||
if ok && ws.conf.ControlProtection && sess == CONTROL_PROTECTION_SESSION && len(ws.sessions.Admins()) > 0 {
|
||||
ws.state.Unlock("control")
|
||||
ws.sessions.SetControlLocked(false) // TODO: Handle locks in sessions as flags.
|
||||
ws.logger.Info().Msgf("control unlocked on behalf of control protection")
|
||||
|
||||
if err := ws.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_UNLOCK,
|
||||
ID: e.Id,
|
||||
Resource: "control",
|
||||
}, nil); err != nil {
|
||||
ws.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK)
|
||||
}
|
||||
}
|
||||
|
||||
// remove outdated stats
|
||||
if e.Session.Admin() {
|
||||
ws.lastAdminLeftAt = nil
|
||||
@ -157,25 +112,6 @@ func (ws *WebSocketHandler) Start() {
|
||||
membersCount := len(ws.sessions.Members())
|
||||
adminCount := len(ws.sessions.Admins())
|
||||
|
||||
// if control protection is enabled and no admin
|
||||
// and room is not locked, lock
|
||||
ok := ws.state.IsLocked("control")
|
||||
if !ok && ws.conf.ControlProtection && adminCount == 0 {
|
||||
ws.state.Lock("control", CONTROL_PROTECTION_SESSION)
|
||||
ws.sessions.SetControlLocked(true) // TODO: Handle locks in sessions as flags.
|
||||
ws.logger.Info().Msgf("control locked and released on behalf of control protection")
|
||||
ws.handler.AdminRelease(e.Id, e.Session)
|
||||
|
||||
if err := ws.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_LOCK,
|
||||
ID: e.Id,
|
||||
Resource: "control",
|
||||
}, nil); err != nil {
|
||||
ws.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK)
|
||||
}
|
||||
}
|
||||
|
||||
// if this was the last admin
|
||||
if e.Session.Admin() && adminCount == 0 {
|
||||
now := time.Now()
|
||||
@ -216,37 +152,6 @@ func (ws *WebSocketHandler) Start() {
|
||||
ws.logger.Err(err).Msg("sync clipboard")
|
||||
}
|
||||
}()
|
||||
|
||||
// watch for file changes and send file list if file transfer is enabled
|
||||
if ws.conf.FileTransferEnabled {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
ws.logger.Err(err).Msg("unable to start file transfer dir watcher")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
ws.logger.Info().Msg("file transfer dir watcher closed")
|
||||
return
|
||||
}
|
||||
if e.Has(fsnotify.Create) || e.Has(fsnotify.Remove) || e.Has(fsnotify.Rename) {
|
||||
ws.logger.Debug().Str("event", e.String()).Msg("file transfer dir watcher event")
|
||||
ws.handler.FileTransferRefresh(nil)
|
||||
}
|
||||
case err := <-watcher.Errors:
|
||||
ws.logger.Err(err).Msg("error in file transfer dir watcher")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := watcher.Add(ws.conf.FileTransferPath); err != nil {
|
||||
ws.logger.Err(err).Msg("unable to add file transfer path to watcher")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) Shutdown() error {
|
||||
@ -290,7 +195,7 @@ func (ws *WebSocketHandler) Upgrade(w http.ResponseWriter, r *http.Request) erro
|
||||
socket := &WebSocket{
|
||||
id: id,
|
||||
ws: ws,
|
||||
address: utils.GetHttpRequestIP(r, ws.conf.Proxy),
|
||||
address: r.RemoteAddr,
|
||||
connection: connection,
|
||||
}
|
||||
|
||||
@ -347,21 +252,15 @@ func (ws *WebSocketHandler) Stats() types.Stats {
|
||||
Members: ws.sessions.Members(),
|
||||
|
||||
Banned: ws.state.AllBanned(),
|
||||
Locked: ws.state.AllLocked(),
|
||||
|
||||
ServerStartedAt: ws.serverStartedAt,
|
||||
LastAdminLeftAt: ws.lastAdminLeftAt,
|
||||
LastUserLeftAt: ws.lastUserLeftAt,
|
||||
|
||||
ControlProtection: ws.conf.ControlProtection,
|
||||
ImplicitControl: ws.webrtc.ImplicitControl(),
|
||||
ImplicitControl: ws.webrtc.ImplicitControl(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) IsLocked(resource string) bool {
|
||||
return ws.state.IsLocked(resource)
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) IsAdmin(password string) (bool, error) {
|
||||
if password == ws.conf.AdminPassword {
|
||||
return true, nil
|
||||
@ -444,28 +343,3 @@ func (ws *WebSocketHandler) handle(connection *websocket.Conn, id string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// File transfer
|
||||
//
|
||||
|
||||
func (ws *WebSocketHandler) CanTransferFiles(password string) (bool, error) {
|
||||
if !ws.conf.FileTransferEnabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
isAdmin, err := ws.IsAdmin(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return isAdmin || !ws.state.IsLocked("file_transfer"), nil
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) FileTransferPath(filename string) string {
|
||||
return ws.state.FileTransferPath(filename)
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) FileTransferEnabled() bool {
|
||||
return ws.conf.FileTransferEnabled
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"m1k1o/neko/internal/capture"
|
||||
"m1k1o/neko/internal/config"
|
||||
@ -25,7 +26,7 @@ const Header = `&34
|
||||
/ |/ / _ \/ //_/ __ \ ) ( ')
|
||||
/ /| / __/ ,< / /_/ / ( / )
|
||||
/_/ |_/\___/_/|_|\____/ \(__)|
|
||||
&1&37 nurdism/m1k1o &33%s v%s&0
|
||||
&1&37 nurdism/m1k1o &33%s %s&0
|
||||
`
|
||||
|
||||
var (
|
||||
@ -35,13 +36,8 @@ var (
|
||||
gitCommit = "dev"
|
||||
//
|
||||
gitBranch = "dev"
|
||||
|
||||
// Major version when you make incompatible API changes,
|
||||
major = "2"
|
||||
// Minor version when you add functionality in a backwards-compatible manner, and
|
||||
minor = "8"
|
||||
// Patch version when you make backwards-compatible bug fixes.
|
||||
patch = "0"
|
||||
//
|
||||
gitTag = "dev"
|
||||
)
|
||||
|
||||
var Service *Neko
|
||||
@ -49,11 +45,9 @@ var Service *Neko
|
||||
func init() {
|
||||
Service = &Neko{
|
||||
Version: &Version{
|
||||
Major: major,
|
||||
Minor: minor,
|
||||
Patch: patch,
|
||||
GitCommit: gitCommit,
|
||||
GitBranch: gitBranch,
|
||||
GitTag: gitTag,
|
||||
BuildDate: buildDate,
|
||||
GoVersion: runtime.Version(),
|
||||
Compiler: runtime.Compiler,
|
||||
@ -69,11 +63,9 @@ func init() {
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
Major string
|
||||
Minor string
|
||||
Patch string
|
||||
GitCommit string
|
||||
GitBranch string
|
||||
GitTag string
|
||||
BuildDate string
|
||||
GoVersion string
|
||||
Compiler string
|
||||
@ -81,20 +73,25 @@ type Version struct {
|
||||
}
|
||||
|
||||
func (i *Version) String() string {
|
||||
return fmt.Sprintf("%s.%s.%s %s", i.Major, i.Minor, i.Patch, i.GitCommit)
|
||||
version := i.GitTag
|
||||
if version == "" || version == "dev" {
|
||||
version = i.GitBranch
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s@%s", version, i.GitCommit)
|
||||
}
|
||||
|
||||
func (i *Version) Details() string {
|
||||
return fmt.Sprintf(
|
||||
"%s\n%s\n%s\n%s\n%s\n%s\n%s\n",
|
||||
fmt.Sprintf("Version %s.%s.%s", i.Major, i.Minor, i.Patch),
|
||||
return "\n" + strings.Join([]string{
|
||||
fmt.Sprintf("Version %s", i.String()),
|
||||
fmt.Sprintf("GitCommit %s", i.GitCommit),
|
||||
fmt.Sprintf("GitBranch %s", i.GitBranch),
|
||||
fmt.Sprintf("GitTag %s", i.GitTag),
|
||||
fmt.Sprintf("BuildDate %s", i.BuildDate),
|
||||
fmt.Sprintf("GoVersion %s", i.GoVersion),
|
||||
fmt.Sprintf("Compiler %s", i.Compiler),
|
||||
fmt.Sprintf("Platform %s", i.Platform),
|
||||
)
|
||||
}, "\n") + "\n"
|
||||
}
|
||||
|
||||
type Neko struct {
|
||||
|
Reference in New Issue
Block a user