Open instance in-game

This commit is contained in:
Natsumi
2025-08-16 02:45:55 +12:00
parent 90c6422418
commit b3c58a8c08
11 changed files with 204 additions and 65 deletions
+6
View File
@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading.Tasks;
using librsync.net; using librsync.net;
using Newtonsoft.Json; using Newtonsoft.Json;
using NLog; using NLog;
@@ -165,5 +166,10 @@ namespace VRCX
return null; return null;
} }
public Task<bool> TryOpenInstanceInVrc(string launchUrl)
{
return VRCIPC.Send(launchUrl);
}
} }
} }
+43
View File
@@ -0,0 +1,43 @@
using System.IO.Pipes;
using System.Text;
using System.Threading.Tasks;
namespace VRCX;
public class VRCIPC
{
private const string PipeName = "VRChatURLLaunchPipe";
private static NamedPipeClientStream _ipcClient;
private static void TryConnect()
{
if (_ipcClient != null && _ipcClient.IsConnected)
return;
_ipcClient = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut);
_ipcClient.Connect(1000);
}
public static async Task<bool> Send(string message)
{
TryConnect();
if (_ipcClient == null || !_ipcClient.IsConnected)
{
Dispose();
return false;
}
var bytes = Encoding.UTF8.GetBytes(message);
_ipcClient.Write(bytes, 0, bytes.Length);
var result = new byte[1];
await _ipcClient.ReadExactlyAsync(result, 0, 1);
Dispose();
return result[0] == 1;
}
private static void Dispose()
{
_ipcClient?.Close();
_ipcClient = null;
}
}
+38 -2
View File
@@ -1,24 +1,51 @@
<template> <template>
<el-button v-show="isVisible" @click="confirmInvite" size="mini" icon="el-icon-message" circle /> <el-tooltip
v-if="!isGameRunning || isLinux || gameLogDisabled"
placement="top"
:content="t('dialog.user.info.self_invite_tooltip')"
:disabled="hideTooltips">
<el-button v-show="isVisible" @click="confirmInvite" size="mini" icon="el-icon-message" circle />
</el-tooltip>
<el-tooltip v-else placement="top" :content="t('dialog.user.info.open_in_vrchat_tooltip')" :disabled="hideTooltips">
<el-button @click="openInstance" size="mini" icon="el-icon-message" circle />
</el-tooltip>
</template> </template>
<script setup> <script setup>
import { storeToRefs } from 'pinia';
import { computed, getCurrentInstance } from 'vue'; import { computed, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { instanceRequest } from '../api'; import { instanceRequest } from '../api';
import { checkCanInviteSelf, parseLocation } from '../shared/utils'; import { checkCanInviteSelf, parseLocation } from '../shared/utils';
import { useAppearanceSettingsStore } from '../stores/settings/appearance';
import { useGameStore } from '../stores/game';
import { useLaunchStore } from '../stores/launch';
import { useAdvancedSettingsStore } from '../stores/settings/advanced';
const props = defineProps({ const props = defineProps({
location: String, location: String,
shortname: String shortname: String
}); });
const { t } = useI18n();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { isGameRunning } = storeToRefs(useGameStore());
const { gameLogDisabled } = storeToRefs(useAdvancedSettingsStore());
const { tryOpenInstanceInVrc } = useLaunchStore();
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const isVisible = computed(() => checkCanInviteSelf(props.location)); const isVisible = computed(() => checkCanInviteSelf(props.location));
const isLinux = computed(() => LINUX);
function confirmInvite() { function confirmInvite() {
const L = parseLocation(props.location); const L = parseLocation(props.location);
if (!L.isRealInstance) return; if (!L.isRealInstance) {
return;
}
instanceRequest instanceRequest
.selfInvite({ .selfInvite({
@@ -31,4 +58,13 @@
return args; return args;
}); });
} }
function openInstance() {
const L = parseLocation(props.location);
if (!L.isRealInstance) {
return;
}
tryOpenInstanceInVrc(L.tag, props.shortname);
}
</script> </script>
@@ -413,9 +413,7 @@
<div v-for="room in groupDialog.instances" :key="room.tag" style="width: 100%"> <div v-for="room in groupDialog.instances" :key="room.tag" style="width: 100%">
<div style="margin: 5px 0"> <div style="margin: 5px 0">
<Location :location="room.tag" style="display: inline-block" /> <Location :location="room.tag" style="display: inline-block" />
<el-tooltip placement="top" content="Invite yourself" :disabled="hideTooltips"> <InviteYourself :location="room.tag" style="margin-left: 5px" />
<InviteYourself :location="room.tag" style="margin-left: 5px" />
</el-tooltip>
<el-tooltip placement="top" content="Refresh player count" :disabled="hideTooltips"> <el-tooltip placement="top" content="Refresh player count" :disabled="hideTooltips">
<el-button <el-button
size="mini" size="mini"
+32 -8
View File
@@ -66,13 +66,31 @@
@click="showInviteDialog(launchDialog.location)"> @click="showInviteDialog(launchDialog.location)">
{{ t('dialog.launch.invite') }} {{ t('dialog.launch.invite') }}
</el-button> </el-button>
<el-button <template v-if="isGameRunning">
type="primary" <el-button
size="small" type="default"
:disabled="!launchDialog.secureOrShortName" size="small"
@click="handleLaunchGame(launchDialog.location, launchDialog.shortName, launchDialog.desktop)"> :disabled="!launchDialog.secureOrShortName"
{{ t('dialog.launch.launch') }} @click="handleLaunchGame(launchDialog.location, launchDialog.shortName, launchDialog.desktop)">
</el-button> {{ t('dialog.launch.launch') }}
</el-button>
<el-button
type="primary"
size="small"
:disabled="!launchDialog.secureOrShortName"
@click="handleAttachGame(launchDialog.location, launchDialog.shortName)">
{{ t('dialog.launch.open_ingame') }}
</el-button>
</template>
<template v-else>
<el-button
type="primary"
size="small"
:disabled="!launchDialog.secureOrShortName"
@click="handleLaunchGame(launchDialog.location, launchDialog.shortName, launchDialog.desktop)">
{{ t('dialog.launch.launch') }}
</el-button>
</template>
</template> </template>
<InviteDialog :invite-dialog="inviteDialog" @closeInviteDialog="closeInviteDialog" /> <InviteDialog :invite-dialog="inviteDialog" @closeInviteDialog="closeInviteDialog" />
</safe-dialog> </safe-dialog>
@@ -88,6 +106,7 @@
import { import {
useAppearanceSettingsStore, useAppearanceSettingsStore,
useFriendStore, useFriendStore,
useGameStore,
useInstanceStore, useInstanceStore,
useLaunchStore, useLaunchStore,
useLocationStore useLocationStore
@@ -100,9 +119,10 @@
const { friends } = storeToRefs(useFriendStore()); const { friends } = storeToRefs(useFriendStore());
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore()); const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { lastLocation } = storeToRefs(useLocationStore()); const { lastLocation } = storeToRefs(useLocationStore());
const { launchGame } = useLaunchStore(); const { launchGame, tryOpenInstanceInVrc } = useLaunchStore();
const { launchDialogData } = storeToRefs(useLaunchStore()); const { launchDialogData } = storeToRefs(useLaunchStore());
const { showPreviousInstancesInfoDialog } = useInstanceStore(); const { showPreviousInstancesInfoDialog } = useInstanceStore();
const { isGameRunning } = storeToRefs(useGameStore());
const launchDialogRef = ref(null); const launchDialogRef = ref(null);
@@ -180,6 +200,10 @@
launchGame(location, shortName, desktop); launchGame(location, shortName, desktop);
isVisible.value = false; isVisible.value = false;
} }
function handleAttachGame(location, shortName) {
tryOpenInstanceInVrc(location, shortName);
isVisible.value = false;
}
function getConfig() { function getConfig() {
configRepository.getBool('launchAsDesktop').then((value) => (launchDialog.value.desktop = value)); configRepository.getBool('launchAsDesktop').then((value) => (launchDialog.value.desktop = value));
} }
@@ -568,15 +568,10 @@
<div style="flex: none"> <div style="flex: none">
<template v-if="isRealInstance(userDialog.$location.tag)"> <template v-if="isRealInstance(userDialog.$location.tag)">
<Launch :location="userDialog.$location.tag" /> <Launch :location="userDialog.$location.tag" />
<el-tooltip <InviteYourself
placement="top" :location="userDialog.$location.tag"
:content="t('dialog.user.info.self_invite_tooltip')" :shortname="userDialog.$location.shortName"
:disabled="hideTooltips"> style="margin-left: 5px" />
<InviteYourself
:location="userDialog.$location.tag"
:shortname="userDialog.$location.shortName"
style="margin-left: 5px" />
</el-tooltip>
<el-tooltip <el-tooltip
placement="top" placement="top"
:content="t('dialog.user.info.refresh_instance_info')" :content="t('dialog.user.info.refresh_instance_info')"
@@ -348,15 +348,10 @@
:currentuserid="currentUser.id" :currentuserid="currentUser.id"
:worlddialogshortname="worldDialog.$location.shortName" /> :worlddialogshortname="worldDialog.$location.shortName" />
<Launch :location="room.tag" style="margin-left: 5px" /> <Launch :location="room.tag" style="margin-left: 5px" />
<el-tooltip <InviteYourself
placement="top" :location="room.$location.tag"
:content="t('dialog.world.instances.self_invite_tooltip')" :shortname="room.$location.shortName"
:disabled="hideTooltips"> style="margin-left: 5px" />
<InviteYourself
:location="room.$location.tag"
:shortname="room.$location.shortName"
style="margin-left: 5px" />
</el-tooltip>
<el-tooltip <el-tooltip
placement="top" placement="top"
:content="t('dialog.world.instances.refresh_instance_info')" :content="t('dialog.world.instances.refresh_instance_info')"
+4 -2
View File
@@ -525,7 +525,7 @@
"show_images": "Show world images", "show_images": "Show world images",
"show_current_platform": "Show current platform", "show_current_platform": "Show current platform",
"world_integration": "World integration", "world_integration": "World integration",
"world_integration_tooltip": "Show \"Watching\\Listening to\" video for Popcorn Palace, PyPyDance, VRDancing and LS Media", "world_integration_tooltip": "Show \"Watching/Listening to\" for Popcorn Palace, PyPyDance, VRDancing and LS Media",
"display_world_name_as_discord_status": "Display world name as Discord status" "display_world_name_as_discord_status": "Display world name as Discord status"
}, },
"rpc": { "rpc": {
@@ -785,6 +785,7 @@
"header": "Info", "header": "Info",
"launch_invite_tooltip": "Launch/Invite", "launch_invite_tooltip": "Launch/Invite",
"self_invite_tooltip": "Invite Yourself", "self_invite_tooltip": "Invite Yourself",
"open_in_vrchat_tooltip": "Open in VRChat",
"refresh_instance_info": "Refresh Instance Info", "refresh_instance_info": "Refresh Instance Info",
"instance_queue": "Queue:", "instance_queue": "Queue:",
"instance_users": "Users:", "instance_users": "Users:",
@@ -1321,7 +1322,8 @@
"start_as_desktop": "Start as Desktop (No VR)", "start_as_desktop": "Start as Desktop (No VR)",
"info": "Info", "info": "Info",
"invite": "Invite", "invite": "Invite",
"launch": "Launch" "launch": "Launch",
"open_ingame": "Open in Game"
}, },
"export_friends_list": { "export_friends_list": {
"header": "Export Friends List", "header": "Export Friends List",
+69 -31
View File
@@ -64,50 +64,87 @@ export const useLaunchStore = defineStore('Launch', () => {
* *
* @param {string} location * @param {string} location
* @param {string} shortName * @param {string} shortName
* @param {boolean} desktopMode * @returns {Promise<string>} launchUrl
* @returns {Promise<void>}
*/ */
async function launchGame(location, shortName, desktopMode) { async function getLaunchUrl(location, shortName) {
const L = parseLocation(location); const L = parseLocation(location);
const args = [];
if ( if (
shortName && shortName &&
L.instanceType !== 'public' && L.instanceType !== 'public' &&
L.groupAccessType !== 'public' L.groupAccessType !== 'public'
) { ) {
args.push( return `vrchat://launch?ref=vrcx.app&id=${location}&shortName=${shortName}`;
`vrchat://launch?ref=vrcx.app&id=${location}&shortName=${shortName}`
);
} else {
// fetch shortName
let newShortName = '';
const response = await instanceRequest.getInstanceShortName({
worldId: L.worldId,
instanceId: L.instanceId
});
if (response.json) {
if (response.json.shortName) {
newShortName = response.json.shortName;
} else {
newShortName = response.json.secureName;
}
}
if (newShortName) {
args.push(
`vrchat://launch?ref=vrcx.app&id=${location}&shortName=${newShortName}`
);
} else {
args.push(`vrchat://launch?ref=vrcx.app&id=${location}`);
}
} }
// fetch shortName
let newShortName = '';
const response = await instanceRequest.getInstanceShortName({
worldId: L.worldId,
instanceId: L.instanceId
});
if (response.json) {
if (response.json.shortName) {
newShortName = response.json.shortName;
} else {
newShortName = response.json.secureName;
}
}
if (newShortName) {
return `vrchat://launch?ref=vrcx.app&id=${location}&shortName=${newShortName}`;
}
return `vrchat://launch?ref=vrcx.app&id=${location}`;
}
/**
* launch.exe &attach=1
* @param {string} location
* @param {string} shortName
* @returns {Promise<void>}
*/
async function tryOpenInstanceInVrc(location, shortName) {
const launchUrl = await getLaunchUrl(location, shortName);
let result = false;
try {
result = await AppApi.TryOpenInstanceInVrc(launchUrl);
} catch (e) {
console.error(e);
}
console.log('Attach Game', launchUrl, result);
if (!result) {
$app.$message({
message:
'Failed open instance in VRChat, falling back to self invite',
type: 'warning'
});
// self invite fallback
const L = parseLocation(location);
await instanceRequest.selfInvite({
instanceId: L.instanceId,
worldId: L.worldId,
shortName
});
$app.$message({
message: 'Self invite sent',
type: 'success'
});
}
}
/**
*
* @param {string} location
* @param {string} shortName
* @param {boolean} desktopMode
* @returns {Promise<void>}
*/
async function launchGame(location, shortName, desktopMode) {
const launchUrl = await getLaunchUrl(location, shortName);
const args = [launchUrl];
const launchArguments = const launchArguments =
await configRepository.getString('launchArguments'); await configRepository.getString('launchArguments');
const vrcLaunchPathOverride = await configRepository.getString( const vrcLaunchPathOverride = await configRepository.getString(
'vrcLaunchPathOverride' 'vrcLaunchPathOverride'
); );
if (launchArguments) { if (launchArguments) {
args.push(launchArguments); args.push(launchArguments);
} }
@@ -157,6 +194,7 @@ export const useLaunchStore = defineStore('Launch', () => {
launchDialogData, launchDialogData,
showLaunchOptions, showLaunchOptions,
showLaunchDialog, showLaunchDialog,
launchGame launchGame,
tryOpenInstanceInVrc
}; };
}); });
+1
View File
@@ -201,6 +201,7 @@ declare global {
runProcessOnce: boolean runProcessOnce: boolean
): Promise<void>; ): Promise<void>;
GetFileBase64(path: string): Promise<string | null>; GetFileBase64(path: string): Promise<string | null>;
TryOpenInstanceInVrc(launchUrl: string): Promise<boolean>;
// Folders // Folders
GetVRChatAppDataLocation(): Promise<string>; GetVRChatAppDataLocation(): Promise<string>;
+2 -1
View File
@@ -1207,7 +1207,8 @@
@change=" @change="
setDiscordWorldIntegration(); setDiscordWorldIntegration();
saveDiscordOption(); saveDiscordOption();
" /> "
:tooltip="t('view.settings.discord_presence.discord_presence.world_integration_tooltip')" />
<simple-switch <simple-switch
:label="t('view.settings.discord_presence.discord_presence.instance_type_player_count')" :label="t('view.settings.discord_presence.discord_presence.instance_type_player_count')"
:value="discordInstance" :value="discordInstance"